Agent (AcpPanel):
- Follow state: an isAtBottom ref + state. A scroll listener on
the ScrollArea viewport keeps them in sync. The auto-scroll
effect only fires scrollIntoView when the ref is true, so the
user can scroll up to read history without the panel snapping
back to the bottom.
- Jump-to-latest button: when !isAtBottom, an absolute-positioned
'↓ Jump to latest' pill appears at the bottom of the ScrollArea;
clicking it scrolls back to the bottom and re-enables follow.
- Bottom padding: messages container is now 'p-3 pb-6' and the
trailing sentinel is 'h-8 shrink-0', so the last bubble has
breathing room above the composer and isn't visually clipped.
Terminal (TerminalPanel):
- xterm container is now 'px-2 pt-2 pb-8' so the bottom row of
the terminal isn't covered by the hint/status bar above the
composer (or, in the standalone TerminalPanel, by the tab bar).
Both panels: extra bottom padding so the last line of content is
never hidden by an adjacent control.
Two related bugs in the editor:
1) External file changes were invisible. useFileRead had no
refetchInterval — the query fetched once on mount and never
again. The agent editing the file (or any other tool) showed
nothing in the open editor until a manual reload. Fix: add
refetchInterval: 5_000 and refetchOnWindowFocus: true to
useFileRead (same cadence as useFileList).
2) Even with refetch, the sync effect set both buffer and
savedContent to data.content, which silently overwrote the
user's unsaved edits every 5s. Fix: only overwrite buffer when
!dirty. savedContent still tracks the latest disk version so we
can detect 'file changed externally while editing'.
When the file is dirty AND the disk version differs, show a
'File changed on disk' banner with a Reload button. The user
decides whether to keep their unsaved changes or accept the disk
version. (The dependency lint warning is intentionally suppressed
with a comment explaining the design.)
The previous 'stabilize useMutation results' fix addressed one cause
of /shell/resize being spammed (the mutation object identity), but
not the actual root cause: a closed loop between xterm's onResize
callback, ResizeObserver, and the backend.
The full loop:
1. ResizeObserver fires
2. fitAddon.fit() runs
3. fit() calls terminal.resize(cols, rows)
4. terminal.onResize(cb) fires the listener
5. cb calls resize.mutate() — POSTs to /shell/resize
6. server calls pty.Setsize
7. bash receives SIGWINCH and redraws the prompt
8. the new output writes to the terminal, which can shift a
scrollbar / padding by a few pixels
9. ResizeObserver fires again
-> repeat
Fixes applied to TerminalPanel's terminal-init useEffect:
- Drop the terminal.onResize subscription entirely. Per the xterm
+ FitAddon pattern, the right way to push size to the backend
is: from the ResizeObserver path, call fit(), then read
terminal.cols/rows synchronously and call resize.mutate().
- Dedupe by (cols, rows) — if the size hasn't changed since the
last reported size, skip the mutation.
- Dedupe the ResizeObserver callback by container
getBoundingClientRect — sub-pixel changes (e.g. a scrollbar
appearing) won't trigger a redundant fit().
- Depend on the stable sendResize function (the destructured
mutate from useMutation) rather than the whole resize object,
so unrelated render churn can't tear down and re-create the
terminal.
- Run an initial fit() at mount so the backend learns the real
size before the user starts typing.
The 100ms debounce was a band-aid that didn't actually break the
loop — it only slowed it down.
The mutationFn / onSuccess closures inside every useMutation in
process.ts were created fresh on every render, which made the
mutation result object unstable across renders. TerminalPanel's
terminal-init useEffect has [resize, workspaceId] as deps; with
"resize" changing on every render, the effect re-ran on every
render, which:
- disposed and recreated the xterm Terminal
- re-attached onResize (which fires fit() → onResize → mutate)
- 100ms debounce kept getting reset
- but the effect re-creating itself with new closures every
~16ms kept fit()'s pre-resize state oscillating, eventually
letting the debounce expire and firing the API
Result: /api/workspaces/:id/shell/resize was being spammed.
Fix: wrap every useMutation's mutationFn (and onSuccess where
present) in useCallback with explicit deps. useMutation now sees a
stable config and returns a stable result object. The terminal
effect's dep [resize, workspaceId] no longer changes per render
unless workspaceId actually changes.
Affects: useProcessStart/Stop/Restart, useShellStart/Stop/
Restart/Resize. All same pattern, all same risk.
useWorkspaceUiStore((s) => s.shellsByWorkspace[workspaceId] ?? [])
returned a fresh [] on every render when the workspace had no shells
yet, so useSyncExternalStore saw a new snapshot each render and
re-rendered, which ran the selector again, ad infinitum.
"The result of getSnapshot should be cached to avoid an infinite
loop"
TerminalTabs @ WorkspaceShell.tsx:75
Fix: hoist a module-level EMPTY_SHELLS constant. The selector now
returns a stable reference for the empty case. The non-empty case
already returns the array stored in the Zustand store, whose
reference is set once per setShellsForWorkspace call.
TerminalTabs's hydrate effect set activeShellId to "" whenever the
list was empty or the current active was missing. "" is falsy, so
on the next render the same branch fired again with currentActive
still "" and ids still [] — setActiveShell(ws, "") in a tight
loop, every render triggering a Zustand store update which
re-rendered the component. Maximum update depth exceeded.
Fix: only set the active shell when ids has at least one entry. The
auto-start effect above already creates a shell when the list is
empty, so the next list refetch will populate ids and this branch
will pick a real candidate naturally.
Replace the synchronous useAcpPrompt mutation with a streaming WS
hook. The agent's reply now arrives chunk-by-chunk and the same
agent bubble grows in real time.
- web/src/lib/api/acp.ts:
- Add AcpStreamEvent / AcpStreamStatus types.
- Add useAcpStream(workspaceId) hook: per-prompt WS lifecycle
(idle -> connecting -> open -> closed / error), open(content)
/ close() / onEvent(cb) API. Reuses the reconnection / 1000-1001
/ pinger pattern from useProcessWebSocket.
- Keep useAcpPrompt and useAcpCancel exported for back-compat.
- web/src/components/acp/AcpPanel.tsx:
- Switch to useAcpStream. handleSend pushes an optimistic user
message, opens the stream, sets an inflight agent bubble with
empty text.
- chunk events append to inflight.text; complete clears inflight;
error clears inflight and shows a local error toast.
- renderMessages = coalesced + localUserMessage + inflight.
- Local user message is auto-cleared when the next history poll
surfaces the same text (avoids duplicate render).
- Auto-scroll effect depends on inflight.text so it fires as
chunks stream.
- Cancel button closes the WS (server's read loop triggers
session/cancel).
- Remove the standalone ThinkingIndicator; the in-flight bubble
serves as the streaming affordance.
Conversation: 7B62CB8E-ACC8-4333-BC64-B927C8FDC397
Frontend wiring for the new multi-shell backend. The bottom panel's
Terminal tab now has a tab strip: one tab per shell (showing
shellId[:8] + an X to close), plus a + button to spawn a new shell.
Tab state lives in workspace-ui-store (shellsByWorkspace +
activeShellIdByWorkspace). Auto-start creates the first shell if the
list is empty.
- web/src/lib/api/process.ts:
- useShellStart returns ShellInfo on 201
- useShellStop / useShellRestart take {shellId}
- useShellStatus(workspaceId, shellId) requires both
- useShellWebSocket(workspaceId, shellId, enabled) refactored:
shellId change closes the current socket intentionally and opens
a new one with ?shellId= appended
- useShellResize takes {shellId, cols, rows}
- new useShellList(workspaceId)
- web/src/lib/store/workspace-ui-store.ts: shellsByWorkspace +
activeShellIdByWorkspace maps + setters (addShellToWorkspace,
removeShellFromWorkspace, setActiveShell, setShellsForWorkspace)
- web/src/components/terminal/TerminalPanel.tsx: requires shellId
prop; clears/resets terminal on shellId change (in addition to
workspaceId); welcome banner shows
'[connected to <workspaceId>/<shortShellId>]'
- web/src/components/layout/WorkspaceShell.tsx: new TerminalTabs
component:
- lists shells as tabs, + to add, x to close
- useShellList hydrates the store on mount
- auto-start guard via useRef so we don't double-fire
- close-pending tracked per shellId via Set
- renders the active <TerminalPanel workspaceId shellId>
Diff: 4 files. pnpm lint + pnpm build clean.
Conversation: B975103F-4528-493E-8F8A-91D20506631D
The PTY was hardcoded to 80x24 at start, so full-screen programs
(htop, vim, less, tmux, top) drew themselves for 80x24 regardless
of the actual xterm window. Add a path for the frontend to push the
real cols/rows to the backend, which calls pty.Setsize on the pty
file.
Backend:
- internal/shell/manager.go: Resize(workspaceID, cols, rows) added to
the Manager interface and implemented on LocalManager. Looks up the
session, type-asserts sess.Stdin.(*os.File), calls pty.Setsize.
Validates cols/rows in [1, 10000] and returns CodeBadRequest on
bad input, CodeNotFound when no session.
- internal/service/shell_service.go: ShellService.Resize wrapper that
checks workspace existence first.
- internal/api/shell_handler.go: resize handler, 204 on success.
- internal/api/router.go: register POST /workspaces/:id/shell/resize.
- internal/model/shell.go: ShellResizeRequest{Cols, Rows}.
- internal/shell/manager_test.go: 3 new tests (valid resize via
pty.Getsize round-trip, invalid size, missing session).
Frontend:
- web/src/lib/api/process.ts: useShellResize mutation hook.
- web/src/components/terminal/TerminalPanel.tsx: terminal.onResize
subscription with 100ms debounce; filters 0x0; only fires when
workspaceId is set; cleanup clears timeout + disposes listener.
E2E: 'stty size' after POST /shell/resize {cols:200, rows:50} returns
'50 200'. 0x40 → 400, missing workspace → 404, valid → 204.
Conversation: 019f360a-5eba-7c81-94d3-e5d58ad3c026
The opencode acp binary streams agent_message_chunk notifications with
an EMPTY messageId. The backend was already correct (it groups when a
messageId is present), but the UI rendered every chunk as its own
bubble, so a single 'count 1 to 5' reply showed as 9 separate bubbles.
Backend:
- internal/acp/history.go: add MessageID to Message; new AppendOrCreate
method that finds or creates an entry by messageId and appends text
to it (preserving the original timestamp).
- internal/acp/client.go: agent_message_chunk and user_message_chunk
use AppendOrCreate.
- internal/model/acp.go: expose MessageID in the wire response.
- internal/service/acp_service.go: copy MessageID into the API DTO.
- internal/acp/client_test.go: add TestHistoryAppendOrCreateGroupsByMessageID.
Frontend:
- web/src/components/acp/groupMessages.ts: new pure helper that
coalesces consecutive same-role history entries into a single bubble
(keeps the first chunk's time + messageId).
- web/src/components/acp/AcpPanel.tsx: render coalesced messages;
auto-scroll effect now depends on coalesced so it fires as the
agent streams.
Verified via e2e: 'count from 1 to 5' now returns a single agent entry
with text '1\n2\n3\n4\n5' instead of 9 separate entries.
- terminal: on workspaceId change call terminal.clear() and
terminal.reset() so the previous workspace's scrollback is wiped
before the new shell's output appears.
- terminal: set macOptionIsMeta: false. With the default (true),
Mac Option+key sequences (e.g. Option+[ emits ESC) leak a literal
0x1b into the shell's stdin, which crashes downstream REPLs like
Python's with "invalid non-printable character U+001B".
- web: add react-markdown + remark-gfm + react-syntax-highlighter
- web: new lib/api/acp.ts (useAcpStatus/History/Prompt/Cancel)
- web: new components/acp/ (AcpPanel + MessageBubble + MarkdownView +
ThinkingIndicator). Markdown rendering with code highlighting via
Prism oneDark. Auto-scroll to bottom when near it; error toasts
fade after 5s.
- web: WorkspaceShell now renders a tabbed bottom panel (Terminal |
Agent) when the bottom panel is visible. Tab state in
workspace-ui-store.
- web: StatusBar shows agent status (ready / initializing / error)
when the ACP session is initialized.
- web: pnpm-workspace.yaml: add packages ['.'] so pnpm install works
in this workspace.
Conversation: 019f3562-9382-7e00-ad92-3162341e9274