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.
Remove completed sections (shadcn migration, ACP panel integration, all
related out-of-scope items that landed in v1). Add the new task
'AcpPanel tool-call block UI (session/update tool_call type)' as the
next phase: forward tool_call events through the stream, store them
in history, render them as collapsible blocks above the agent's
text bubble.
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
Adds GET /api/workspaces/:id/acp/stream. Client opens a WebSocket,
sends {"type":"prompt","content":"..."}, and receives a stream of
{"type":"chunk","messageId","text"} events followed by exactly
one {"type":"complete","stopReason"} or {"type":"error","error"}.
Closing the WS early triggers session/cancel.
- internal/acp/messages.go: StreamEvent wire shape.
- internal/acp/client.go:
- streamChs []chan StreamEvent set; AddStream / RemoveStream.
- sendStream non-blocking fanout.
- Client.Stream(ctx, content, out) registers out, sends prompt,
emits complete/error after the prompt response, unregisters.
- handleNotification fans chunk events to all stream consumers.
- notifyWG ensures chunk ordering vs the terminal event.
- internal/acp/service.go: Service.Stream(workspaceID, content, out)
mirrors Prompt (per-workspace lock, 5-min timeout, EnsureReady).
- internal/service/acp_service.go: thin AcpService.Stream wrapper
that maps acp.StreamEvent -> model.AcpStreamEvent.
- internal/model/acp.go: AcpStreamRequest, AcpStreamEvent DTOs.
- internal/api/acp_handler.go: stream WS handler (upgrade, read
prompt, run Stream in a goroutine, write events, ping/pong, Cancel
on client close).
- internal/api/router.go: register the new route.
- internal/acp/transport.go: dispatch notifications synchronously
(vs. goroutine per notification) so chunks preserve order before
the session/prompt response.
- internal/acp/client_test.go: TestClientStreamEmitsChunkAndComplete
with a fake transport that drives a known sequence.
- internal/api/acp_handler_test.go: TestAcpStreamHandlerRoutes
smoke test using a fake opencode acp script.
Existing POST /api/workspaces/:id/acp/prompt is unchanged.
E2E: prompt 'say hi in exactly 3 words' -> 3 chunk events
('Hi',' there','!') + 1 complete {stopReason: 'end_turn'}.
Conversation: 019f3680-200f-79b0-860b-43302e60d0ea
Same shape as the process package refactor:
- LocalManager.mu (sync.Mutex) -> sessionsMu (sync.RWMutex).
Read paths (Status, Subscribe, Stdin, ExitStatus, Resize) take
RLock; write paths (Start, Stop, Restart, List) take Lock.
- LocalManager.exited: was a hand-rolled map[workspaceID]map[shellID]struct{}
guarded by exitedMu; now a sync.Map keyed by shellID only (UUID
is globally unique, no need for the nested map). Helpers
IsExited / MarkAsExited / ClearExited.
- shellOrder stays a plain map; read+written under sessionsMu.
- waitExit remains the sole caller of MarkAsExited; Start /
Stop / Restart call ClearExited.
- New TestShellIsExitedHelpers covers the helper semantics.
go test -race -count=2 ./... clean. Same caveat as the process
package: at this app's concurrency level, neither sync.RWMutex nor
sync.Map measurably beats the previous pair — the change is mostly
stylistic (one fewer lock, no nested maps, more idiomatic Go).
Conversation: 019f3673-d2d5-78f0-a7a9-5e3e91b65933
Apply the suggested optimization on top of the previous race fix.
- internal/process/manager.go: LocalManager.sessionsMu is now an
RWMutex. Read paths (Status, Subscribe lookup, Stdin lookup,
ExitStatus lookup) take RLock; mutating paths (Start, Stop,
Restart) take Lock. exited is now a sync.Map instead of a
hand-rolled map+mutex; reads (IsExited) are lock-free, writes
(MarkAsExited from waitExit, ClearExited on Start/Stop/Restart)
are atomic. Added IsExited / MarkAsExited / ClearExited helpers.
No behavior change. The previous race fix (the only one in the
package) is preserved: waitExit remains the sole caller of
sess.Cmd.Wait(), and all other code paths read the exited marker
rather than cmd.ProcessState.
- ACP: nothing to do. The ACP service talks to opencode through
process.Manager; it never reads cmd.ProcessState directly. The
process package race fix already covers the ACP code path.
Verified: go test -race -count=2 ./... clean across the repo.
Note: at this app's concurrency level (tens of workspaces, accessed
occasionally), neither sync.RWMutex nor sync.Map measurably beats
the previous map+mutex pair — the critical sections are O(1)
lookups with no contention. The change is mostly stylistic
(removes one lock, fewer deadlock surfaces, more idiomatic Go).
E2E: start / status(running) / duplicate(409) / stop / status(stopped)
/ restart / status(running) / stop all behave correctly.
The race: os/exec writes cmd.ProcessState inside cmd.Wait(), which
runs in the waitExit goroutine. Start, Status, Subscribe, and
Restart all read cmd.ProcessState from other goroutines to decide
'still running?'. That's a textbook Go data race — fixed by
TestStartRejectsDuplicateRunningSession under -race.
Fix: port the same pattern the shell package uses for its multi-shell
refactor. The manager now owns an exited map[string]struct{} guarded
by exitedMu. waitExit is the ONLY place that calls cmd.Wait(); after
Wait() returns it records exited[workspaceID]. Everyone else checks
the exited map instead of reading cmd.ProcessState.
- internal/process/manager.go: add exited + exitedMu to LocalManager;
Start conflict check, Status, Subscribe, Restart, Stop all consult
the exited map; Start clears the entry on new process; Stop and
Restart delete the entry on stop; waitExit sets it after Wait().
- go.mod / go.sum: tidied (uuid v1.6.0 was already direct dep from
the shell refactor).
go test -race -count=3 ./... clean across the whole repo.
go test -race -count=5 ./internal/process/... clean.
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
BREAKING: every shell operation now requires a shellId. The Manager
previously keyed by workspaceID alone (one bash per workspace). It now
keys by (workspaceID, shellID) where shellID is a UUID returned by
Start.
Fixes the long-standing bug where the WS handler's closeAll called
stdin.Close() and killed bash when a WS client disconnected. The
Session owns the pty file; the WS handler no longer closes it. The
pty is only closed by Manager.Stop (explicit) or by captureOutput
when the process naturally exits (EOF).
- internal/shell/manager.go: Manager interface gains List and every
method takes shellID; storage becomes
map[workspaceID]map[shellID]*Session; Start returns (shellID, err)
via uuid.NewString; Resize/Status/ExitStatus/Subscribe/Stdin/Stop
route by shellID; new List(workspaceID) returns ShellInfo[] in
creation order.
- internal/shell/session.go: Session gains ShellID + CreatedAt; Status
type gains ShellID.
- internal/shell/manager_test.go: updated existing tests for new
signatures; added TestShellMultiInstance (two shells in one
workspace, no output cross-talk, independent stop, List behavior).
- internal/service/shell_service.go: wrappers carry shellID; new
List method.
- internal/service/workspace_service.go: auto-start captures/logs
shellID; Delete iterates and stops all workspace shells.
- internal/api/shell_handler.go: WS closeAll drops stdin.Close();
start/restart return 201 with {shellId, pid, ...}; new list handler;
stop/resize take shellId in body.
- internal/api/router.go: GET /api/workspaces/:id/shell (list).
- internal/model/shell.go: new ShellStartResponse, ShellInfo,
ShellListResponse, ShellStopRequest, ShellRestartRequest; updated
ShellStatusResponse + ShellResizeRequest to carry shellId.
- go.mod/go.sum: github.com/google/uuid.
E2E:
- workspace create -> 1 auto shell
- start 2 more -> 3 shells in list
- stop 1 -> 2 shells in list
- WS connect -> send cmd -> disconnect -> WS reconnect -> send cmd ->
response OK, no [process exited] banner
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 shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran
without a tty. Programs that check isatty() (python, htop, less,
vim-style REPLs) would drop echo, ignore line-editing, and leak literal
0x1b bytes into the program — Python's REPL reported 'invalid
non-printable character U+001B' on input.
Switch to github.com/creack/pty:StartWithSize with an 80x24 default
and TERM=xterm-256color in cmd.Env. The PTY file replaces both the
stdin and stdout pipes; sess.Stdin is the pty *os.File and the
output capture goroutine reads from it directly. waitExit no longer
needs to close sess.Stdin — the capture goroutine's defer handles it
when the process dies and the pty returns EOF.
- go.mod / go.sum: add creack/pty v1.1.24.
- internal/shell/manager.go: rewrite Start to use pty.StartWithSize;
captureOutput reads from the pty; waitExit simplified; race-clean
exited map to avoid the cmd.ProcessState data race.
- internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal
(echo with echo + output interleaved proves PTY echo is on) and
TestShellPTYTermEnv (TERM=xterm-256color propagates).
E2E against the real bash: 'python3 -q' returns the >>> prompt,
print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak.
Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
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".
The ACP spec defines session/update content as a ContentBlock object
({type, text}) but we decoded it as a string. This dropped every
agent_message_chunk and left prompt responses empty. E2E against the
real opencode acp binary surfaced the bug.
- internal/acp/messages.go: add ContentBlock struct; Update.Content is
now ContentBlock.
- internal/acp/client.go: handleNotification extracts Text when
Content.Type == "text"; other types are logged at debug and dropped.
- internal/acp/client_test.go: NDJSON framing test sends content as a
ContentBlock object and reads .Text.
- README.md: document /acp/* routes + CODESPACE_OPENCODE_ARGS env.
Verified end-to-end: POST /acp/prompt returns text="Hi there!", 4
messages in history (1 user + 3 agent chunks), 0 invalid session/update
log entries.
Conversation: 019f357d-4236-7010-949c-e61067618d42
- 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
Adds a minimal but real ACP stack for the opencode process:
- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
process stdio. Implements initialize / session/new / session/prompt /
session/cancel. Serves fs/read_text_file and fs/write_text_file from the
workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
notification dispatch, agent-initiated request handling (fs + terminal).
Existing process WS endpoint and Shell subsystem are unchanged.
Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19