design phase 9 portal ui - sinrimin/harness-nexus GitHub Wiki

Design: Phase 9 W5+W6 โ€” modal containers & the portal-style chat UI

Status: SHIPPED 2026-09 (W5: feat/9w5-modal-containers, W6: feat/9w6-portal-chat). Post-ship notes in ยง"What landed". Two user asks, one doc: (W5) a reusable modal container so list pages stop inlining create forms; (W6) the chat surface rebuilt as Agent cards โ†’ session page (left: sessions grouped by workspace; right: portal-style row-sequence stream), with machine base workspace + daemon-routed directory picking. References: research-phase-9-portal-chat-ui.md (portal distillation), design-phase-8-c5.md (chat transport โ€” unchanged), Signal system (AGENTS.md ยง Web UI design system).

W5 โ€” modal container mechanism

Problem

Five pages render their create form as an always-mounted Card below the list (Profiles, Credentials, McpManagement, Machines enroll, Users). Every new entity makes the page longer and the list harder to scan; one page (McpManagement's ImportJsonDialog) hand-rolls raw radix primitives instead of the shared wrapper.

Mechanism

One new primitive, apps/web/src/components/ui/form-dialog.tsx:

  • Built on the existing ui/dialog.tsx wrapper (portal, overlay, focus, ESC/close button). Props: open, onClose, title, description?, size: 'sm' | 'md' | 'lg' | 'xl' (max-w-sm/md/lg/4xl โ€ฆ 2xl for the skill bundle editor), children, footer?.
  • <FormDialogHeader> title/description styling fixed here so every create dialog looks identical. Forms inside stay controlled by their page (the editing: T | 'new' | null conditional-render pattern from Resources.tsx is the reference; W5 just gives it a shared shell).

Page conversions (behavior-preserving, create-only stays create-only)

Page Change
Resources.tsx ResourceEditor re-shelled onto FormDialog (no logic change; skill editor = xl)
Profiles.tsx CreateProfile Card โ†’ dialog; page header gains a "ๆ–ฐๅปบ" button (editing-style state)
Credentials.tsx CreateCredential Card โ†’ dialog + header button
McpManagement.tsx CreateMcpServer Card โ†’ dialog + header button; ImportJsonDialog rebuilt on FormDialog, raw radix import deleted
Machines.tsx EnrollCard Card โ†’ dialog + header button ("ๆณจๅ†Œๆœบๅ™จ")
Users.tsx inline create form โ†’ dialog + header button

No route, SDK, or API changes in W5. All copy via the page's i18n string file (en/zh side by side). MachineDetail is untouched (its forms are contextual sub-cards of a detail page, not list-page create flows).

W6 โ€” portal chat

Goals (from the user's asks)

  1. /chat shows Agent cards (one per AgentInstance โ€” detected + deployed); clicking enters that Agent's session page.
  2. Session page: left session list grouped by workspace (cwd), right portal-style message stream (row sequence: user bubble / assistant markdown / collapsible reasoning / rich tool cards / turn tail).
  3. New sessions require picking a directory on the machine โ€” the machine declares a baseWorkspace; the picker lists only its subdirectories, routed through the daemon.
  4. Presentation follows the portal reference (research-phase-9-portal-chat-ui.md) in Signal clothing.

Data model (migration 0013)

  • machines.base_workspace TEXT NULL โ†’ Machine.baseWorkspace: string | null. Set via PATCH /api/machines/:id (owner-or-admin, like today's fields; absolute path, 1..1024). Unset โ‡’ the chat picker prompts to set it first.
  • ac_sessions.cwd TEXT NULL โ†’ AcSession.cwd โ€” the session's working directory (chosen at open; null only for pre-W6 rows). Drives grouping.
  • ac_sessions.title TEXT NULL โ†’ AcSession.title โ€” derived once from the FIRST prompt (first text block's first line, whitespace-collapsed, โ‰ค80 chars); never overwritten. Sessions with no prompt stay untitled.

API surface

  • PATCH /api/machines/:id โ€” accepts baseWorkspace: string | null (updateMachineSchema gains the field; the refine widens).
  • GET /api/machines/:id/workspace?path=<abs> (new, modules/workspace.ts) โ€” one level of subdirectories only under baseWorkspace: { path, directories: [{ name, path }] } (โ‰ค512, name-sorted, hidden .* entries skipped, symlinks not followed in v1). Gates, in order: owner-or-admin with 404-hiding โ†’ baseWorkspace set (400 WORKSPACE_ROOT_NOT_SET) โ†’ path containment: resolve(path) must equal the root or sit under it (400 WORKSPACE_OUTSIDE_ROOT) โ†’ online (409 MACHINE_OFFLINE) โ†’ daemon capability workspace (409 DAEMON_NO_WORKSPACE) โ†’ daemon reply within WORKSPACE_TIMEOUT_MS (default 10s; 504 WORKSPACE_TIMEOUT; daemon error arm โ†’ 502 DAEMON_WORKSPACE_FAILED).
  • GET /api/agent-instances/:id (new) โ€” { agent, machine } for the session page's header + gating state. Owner-ONLY (chat is owner-only by design; admins get the 404).
  • GET /api/agent-instances/:id/sessions โ€” rows now carry cwd + title.
  • chat:session.open (socket) โ€” accepts directory?:
    • absent โ†’ legacy default (agent's install directory);
    • present โ†’ machine.baseWorkspace must be set (WORKSPACE_NOT_SET) and the resolved directory must be the root or under it (WORKSPACE_INVALID); validation runs BEFORE the session-limit gate's side effects. The chosen directory is stored as the row's cwd and sent as chat:session.start.cwd (the daemon already honors cwd for the subprocess and session/new).
  • SDK: updateMachine({ baseWorkspace }), listMachineWorkspace(machineId, path?), getAgentInstance(id); MachineView.baseWorkspace, AcSessionView.{cwd,title}.

Realtime (/ctl) โ€” workspace:list

Request { requestId, path } โ†’ reply workspace:list with { requestId, directories: [{ name, path }] } or { requestId, error } โ€” same inventory-style waiter pattern as the W4 config viewer, in a new server/src/realtime/workspace.ts (WorkspaceCoordinator: awaitList/onList/ failMachine; disconnect fails waiters). The daemon lists readdir(withFileTypes), keeps isDirectory() entries (symlinks therefore excluded), drops .*, sorts by name, caps 512. The daemon performs NO containment check of its own (the server already validated; the daemon trusts the server like every other /ctl instruction). Capability tag workspace (daemon 0.9.0-p9w6).

Wire enrichment โ€” rich tool cards need more than title/status

acpToolCallViewSchema gains (all optional, backward compatible):

  • toolName (โ‰ค128) โ€” the card registry key. Daemon-side extraction: update.toolCallUpdate.toolName if an adapter provides it, else the Claude _meta.claudeCode.toolName on the session/update envelope.
  • rawInput (record(string โ†’ unknown)) โ€” arguments for summaries/diffs; daemon drops it if JSON.stringify exceeds 32 KiB (Write-style payloads).
  • content (โ‰ค16 items) โ€” ACP ToolCallContent: { type: 'content' | 'diff' | 'terminal', content?: { type, text? }, path?, oldText?, newText?, terminalId? } โ€” the structured arm Edit/Write cards prefer.
  • output (โ‰ค100 000 chars) โ€” rawOutput text (Read/Bash/Grep bodies).

mapAcpUpdate fills them; the server's existing re-validation relays them to every viewer. permission_request views inherit the same enrichment for free (rawInput gives the permission card an argument brief).

Web

Routes (App.tsx):

  • /chat โ€” Agent cards, grouped by machine (section header = machine name + online dot + remote-chat state). Card: agent name, target badge, source (deploy/detected), machine, chatability state. Click โ†’ /chat/agents/:id. Blocked states render as hints on the card (offline / remote chat off / no agentโ€ฆ cards stay visible โ€” honesty over hiding).
  • /chat/agents/:agentId โ€” the session page. AppShell gains a full variant for this route (main drops max-w-6xl + padding; page height = viewport โˆ’ header). Two panes:
    • Left (280px): "ๆ–ฐๅปบไผš่ฏ" button (opens the directory picker) + session list grouped by cwd โ€” group header = basename(cwd) with full path tooltip, groups ordered by newest session, sessions within by openedAt desc showing title ?? id.slice(0,10) + relative time. Open sessions are clickable and highlighted; closed rows render muted (no transcript replay in v1 โ€” clicking shows the "ๅทฒๅ…ณ้—ญ" hint, never a fake history).
    • Right: toolbar (agent name/target, live-turn indicator = the view's single --signal spend, close button) + the stream + composer.
  • Directory picker (components/chat/dir-picker.tsx): FormDialog (W5 primitive) + lazy tree โ€” root = machine baseWorkspace, children fetched via listMachineWorkspace on expand, directories only. If baseWorkspace is unset the dialog offers an inline "set base workspace" field (updateMachine) first. Confirm โ‡’ chat:session.open { agentInstanceId, directory }.

Stream rendering (components/chat/, all Signal-styled, i18n'd, module-scope sub-components; adapted from the reference per the research note):

  • fold.ts โ€” the row model + pure reducer over our semantic events: user_message โ†’ user row + turn begin; message_delta/thought_delta โ†’ append-or-open assistant steps; tool_call โ†’ upsert tool row AND settle the current step (status pending|in_progress โ†’ running); turn_result โ†’ sweep + turn-tail (duration measured client-side; usage from usage events); raw hnx/prompt-error โ†’ system row (error tone). Permissions stay a separate slice rendered inline under the stream, from payload options.
  • chat-stream.tsx โ€” stick-to-bottom scroll container (threshold + programmatic flag + ResizeObserver + jump-to-bottom button).
  • markdown-text.tsx โ€” react-markdown + remark-gfm + rehype-highlight; streaming caret on the live last block; code blocks in IBM Plex Mono.
  • reasoning-row.tsx โ€” disclosure row ("ๆ€่€ƒ", last-line-follow while running, first-line when settled, expandable full text, "ๆ€่€ƒไธญโ€ฆ" placeholder).
  • tool-card.tsx + blocks โ€” three-tier resolution (toolName โ†’ ACP kind โ†’ generic), error-boundary-wrapped; blocks: ReadBlock (line numbers + highlight + head/tail cap 8 + copy), DiffBlock (pure-text-color diff, โ”” +A -R footer), TerminalBlock (status dot + [dir] $ cmd banner + capped output), SearchBlock (grouped matches / path list), IoCard (IN/OUT), and a plain TodoCard. Status colors follow the established state encoding (ok/danger/warn); running rows get a subtle pulse, not the reference shimmer.
  • turn-tail.tsx โ€” muted mono line: duration ยท tokens ยท ๅทฒๅ–ๆถˆ.

New web deps: react-markdown, remark-gfm, rehype-highlight + highlight.js (CSS imported locally; self-hosted only โ€” no CDN, consistent with the font policy).

MachineDetail.tsx: the Deployments card's Chat link points at /chat/agents/:agentId; the header action row gains a small base-workspace field (owner). Old Chat.tsx is deleted (routes replaced).

Security & scope notes

  • Chat stays owner-only; workspace listing is owner-or-admin read (404-hiding) but only the owner can open sessions.
  • Directory validation is server-side at BOTH open and list time (resolve + prefix containment against baseWorkspace); the daemon additionally only ever returns directory names, never file contents.
  • No transcript persistence (unchanged): AcSession rows stay audit-only; closed sessions are inert list entries.
  • promptBlockSchema already accepts resource_link โ€” @-file mentions and slash commands are deferred (the adapters don't surface commands; the browser fs-browse popover is a separate channel).

Out of scope

  • Session resume/replay (needs transcript persistence โ€” a future wave), fork/delete of agent-side sessions, IDE/terminal panels, Mermaid rendering, @-mentions + slash-command palette, sub-agent trace modals.
  • Editing existing credentials/MCP servers/profiles (W5 moves create flows into modals; edit paths remain what they are today).

Test & verification plan

  • server: chat.test.ts โ€” open with directory (happy, outside root โ†’ WORKSPACE_INVALID, unset root โ†’ WORKSPACE_NOT_SET, absent โ†’ legacy default), title derived on first send only; workspace.test.ts โ€” coordinator unit + route gates (offline/capability/timeout/containment) mirroring config-view.test.ts; machines PATCH round-trips baseWorkspace.
  • cli: chat.test.ts โ€” enriched toolCallView (toolName from _meta, diff content passthrough, rawInput 32 KiB drop, output cap); workspace.test.ts โ€” tmpdir listing (dirs only, hidden skipped, 512 cap).
  • smoke: [9 W6] โ€” PATCH baseWorkspace, real-daemon workspace listing, chat open with directory against the fixture ACP agent.
  • web: tsc + vite build; browser walkthrough on the dev rig (cards โ†’ session page โ†’ picker โ†’ fixture-agent turn with tool calls rendering).

What landed (2026-09)

Both waves shipped as designed, with these deviations worth knowing:

  • W5 also fixed a dev-only vite proxy bug the walkthrough surfaced: the '/mcp' prefix proxy swallowed the app's own /mcp-servers page route โ€” a bypass now rewrites it to the SPA shell (vite.config.ts).
  • W6 wire: acpToolCallView normalization happens daemon-side in toolCallView/buildView (packages/cli/src/daemon/chat.ts) โ€” kind variants like readTool fold to the spec short form, unknown statuses are dropped (field-by-field extraction, never a whole-view fallback), and rawInput is dropped wholesale past 32 KiB (Write-style bodies).
  • W6 route bug found by its own test: the daemon error arm must be mapped BEFORE the generic !outcome.ok โ†’ 504 branch, or a fast error reply misreports as a timeout (modules/workspace.ts).
  • The session page's left rail is desktop-only (hidden md:flex); mobile falls back to the right-pane-only layout (a mobile drawer is future work).
  • The directory picker's first click on a collapsed folder EXPANDS it; a second click (or the root row / manual path input) selects. Expanding an empty folder renders it selectable.
  • The fixture ACP agent gained a show-tools prompt arm (test/fixtures/acp-agent.mjs) emitting _meta.claudeCode.toolName-tagged Read/Bash/Edit calls with rawOutput + structured diff โ€” the reference payload for the rich-card tests and walkthroughs.
  • Tests: server 99 (workspace route suite + chat cwd/title suite), cli 67 (enrichment + listDirectories), shared 69; smoke 380/380 including a [9 W6] section (real-daemon workspace listing + gates).

Post-ship update โ€” rail status dots (#12, 2026-09-23, user-found)

ZCode-style status marks on the session rail, replacing the fuzzy ones: RUNNING = a static green dot (--ok) + the working label, driven by the attached channel's live busy flag โ€” the old dot was gated on the row being viewed, conflating view state with run state. OPENED = a blue dot (--signal, the system's live color) instead of the muted gray one. The channel tab strip mirrors the green dot for busy channels (the tab change itself is noted in the W11 doc). Rig-verified in both states.

Post-ship update โ€” rail rework (#13, 2026-09-23, same-day refinement)

#12 got the dot COLORS right but not the layout. Reworked to the ZCode reference: single-line rows (title truncated + relative time โ€” the "Resume"/"open"/"working" second line is gone, details in the tooltip), rows indented under the folder header with the status dot in the freed left gutter, per-cwd COLLAPSIBLE folders, and a hover "+" per folder that opens a channel with that cwd DIRECTLY (no DirPicker).

Also fixed the stale-open bug hit during testing: rows consulted the listing's moment-in-time openChannelId stamp for "open", so a channel closed in another window left a dead blue dot until the next listing refresh. The user-wide chat:channels push is realtime for every window โ€” the stamp is dropped; a row is open iff the live channel map contains its native session id. Rejoin-on-click now uses that channel's id directly.

Post-ship update โ€” live title sync (#14, 2026-09-23)

New sessions sat on "Untitled" until an unrelated listing refresh: titles are written by each TARGET's native store around the first turn (claude-code/codex/opencode derive from the first prompt โ€” claude later REPLACES it with an async summary; dsh/pi scan the transcript file), and the rail only pulled on mount / channel ready / close / manual refresh.

The fix is deliberately two small pieces (an earlier draft polled for the summary replacement โ€” cut as over-engineered in review): the ACTIVE channel's row shows the fold's FIRST USER PROMPT as an optimistic title while the vendor title is absent, and every TURN END pulls the listing once past the daemon TTL and re-renders. A late async rewrite surfaces on the next turn end or a manual refresh โ€” good enough, no polling.

Post-ship update โ€” the rail rebuilt from the comp's devices (#23, 2026-09-25)

#12 and #13 settled the rail's behavior (dot semantics, collapsible folders, rejoin-on-click, live titles); its dress was still hand-rolled โ€” a header printing a directory's basename with the path in a tooltip, one-line rows with a rounded hover block, literal type sizes. That reads as foreign in every skin, because it is the one band on screen no skin can reach; the comp has devices for it (chat.html):

  • Folder header = a tray strip (bg-tray): LabelText "ๅทฅไฝœ็›ฎๅฝ•" + a collapse caret, then the directory itself as a copyable Well โ€” the path is data the user copies, not a tooltip.
  • A session row = two lines (.sess): title (active row: heavier ink on the tray) over a mono meta line relative time ยท short session id; hairline separators between rows; the status lamp top-aligned in the left gutter so a two-line row keeps one sight-line; the rail's refresh control reads the small control token (--control-h-sm).

Structure, gating and behavior from #12/#13 are unchanged. The general rule this follows โ€” bands and lists are composed from kit devices, never hand-rolled โ€” is stated in design-skin-system.md ยงDevices.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ