Commit Graph
39 Commits
Author SHA1 Message Date
tao.chen 443b38e3bd fix(terminal): remove top padding on xterm container 2026-07-06 18:53:26 +08:00
tao.chen 35b1bc4357 feat(web): agent auto-scroll (follow mode + jump-to-latest button) and bottom padding on terminal and agent
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.
2026-07-06 18:46:47 +08:00
tao.chen 8bb0bdfb4c fix(web): editor picks up external file changes and warns on conflict
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.)
2026-07-06 18:33:02 +08:00
tao.chen c647bb98a9 fix(web): break the terminal resize loop properly
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.
2026-07-06 18:23:11 +08:00
tao.chen 3df9f42626 fix(web): stabilize useMutation results so TerminalPanel stops flooding /shell/resize
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.
2026-07-06 18:19:57 +08:00
tao.chen fc4bed67f4 fix(web): cache empty shells array in TerminalTabs selector
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.
2026-07-06 18:10:03 +08:00
tao.chen 0122b8bb07 fix(web): break infinite loop in TerminalTabs when shell list is empty
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.
2026-07-06 17:37:05 +08:00
tao.chen ec54ea4473 feat(web): AcpPanel streaming over WebSocket
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
2026-07-06 17:17:47 +08:00
tao.chen 7b40b23d33 feat(web): multi-terminal tabs per workspace
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
2026-07-06 15:48:12 +08:00
tao.chen f5a6ff8b0d feat(shell): PTY resize via HTTP + frontend xterm.onResize hook
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
2026-07-06 14:16:09 +08:00
tao.chen 789f62669c fix(acp): group agent chunks by messageId (backend) and coalesce consecutive same-role entries in the UI (frontend)
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.
2026-07-06 13:48:56 +08:00
tao.chen 041c3dc002 fix(web): clear terminal on workspace change; disable Mac Option as Meta
- 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".
2026-07-06 13:39:20 +08:00
tao.chen a633af272b feat(web): add ACP chat panel with tabs (Terminal | Agent)
- 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
2026-07-06 11:30:43 +08:00
tao.chen 18cb3a0151 feat(web): replace workspace native select with shadcn select 2026-07-03 18:48:01 +08:00
tao.chenandClaude c065079d87 feat(web): migrate prompts and confirms to shadcn dialog and dropdown
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:38:28 +08:00
tao.chenandClaude 3c6e0c3121 fix(web): move save logic from monaco to react with manual and auto-save
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:25:47 +08:00
tao.chenandClaude 7a6e7e7ebb feat(web): replace hover buttons with right-click context menu in file tree
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 17:40:07 +08:00
tao.chenandClaude fcb678773c feat(web): suppress browser ctrl+s and add file tree CRUD
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 17:17:55 +08:00
tao.chenandClaude ac87454711 feat(web): add file tree manual refresh + 5s polling + focus refetch
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:57:20 +08:00
tao.chenandClaude 53fe249dd6 feat(web): wire terminal panel to workspace shell ws
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:48:49 +08:00
tao.chenandClaude 75e807c154 fix(web): only connect terminal ws when process is running
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:15:51 +08:00
tao.chenandClaude 11ac0299a6 fix(web): treat server clean close as session end without reconnect
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:07:46 +08:00
tao.chenandClaude 60524bad0e fix(web): show toolbar and status bar when no workspace selected
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:00:58 +08:00
tao.chenandClaude 09150c2a7d fix(web): enable websocket proxying in vite dev server
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:47:43 +08:00
tao.chenandClaude 03377eec3c docs: document terminal websocket endpoint
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:36:35 +08:00
tao.chenandClaude d8c8c7fc83 feat(web): wire terminal panel to live process websocket
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:36:12 +08:00
tao.chenandClaude fa5b53939d feat(web): add useProcessWebSocket hook
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:28:42 +08:00
tao.chenandClaude a7be9d3f0c feat: wire OpenCode process control UI
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:46:13 +08:00
tao.chenandClaude 58aa6077d8 feat: wire Monaco editor to real file read/write
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:27:19 +08:00
tao.chenandClaude d587c94f50 feat: wire file explorer to real backend API
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:19:30 +08:00
tao.chenandClaude 867f1d204e feat: wire workspace CRUD UI to backend
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:05:56 +08:00
tao.chenandClaude b3d0b63f4b fix: defer xterm fit to ResizeObserver ticks
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:03:05 +08:00
tao.chenandClaude b5fababeea docs: add web frontend usage
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 19:38:19 +08:00
tao.chenandClaude 062cde88f8 feat: add web editor and terminal panels
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 18:35:17 +08:00
tao.chenandClaude 0f634981b5 feat: add resizable workspace shell
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 18:15:52 +08:00
tao.chenandClaude 593d2aee5e feat: wire web app infrastructure
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 17:23:48 +08:00
tao.chenandClaude 27503781fa feat: add web ui foundation
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 16:57:37 +08:00
tao.chenandClaude 4039d9fa48 feat: scaffold web frontend
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 16:48:19 +08:00
tao.chenandClaude Fable 5 411ed1f8ba feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:07:54 +08:00