design phase 9 portal ui - sinrimin/harness-nexus GitHub Wiki
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).
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.
One new primitive, apps/web/src/components/ui/form-dialog.tsx:
- Built on the existing
ui/dialog.tsxwrapper (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 (theediting: T | 'new' | nullconditional-render pattern from Resources.tsx is the reference; W5 just gives it a shared shell).
| 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).
-
/chatshows Agent cards (one per AgentInstance โ detected + deployed); clicking enters that Agent's session page. - 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).
- New sessions require picking a directory on the machine โ the machine
declares a
baseWorkspace; the picker lists only its subdirectories, routed through the daemon. - Presentation follows the portal reference
(
research-phase-9-portal-chat-ui.md) in Signal clothing.
-
machines.base_workspace TEXT NULLโMachine.baseWorkspace: string | null. Set viaPATCH /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.
-
PATCH /api/machines/:idโ acceptsbaseWorkspace: string | null(updateMachineSchemagains the field; the refine widens). -
GET /api/machines/:id/workspace?path=<abs>(new,modules/workspace.ts) โ one level of subdirectories only underbaseWorkspace:{ path, directories: [{ name, path }] }(โค512, name-sorted, hidden.*entries skipped, symlinks not followed in v1). Gates, in order: owner-or-admin with 404-hiding โbaseWorkspaceset (400WORKSPACE_ROOT_NOT_SET) โ path containment:resolve(path)must equal the root or sit under it (400WORKSPACE_OUTSIDE_ROOT) โ online (409MACHINE_OFFLINE) โ daemon capabilityworkspace(409DAEMON_NO_WORKSPACE) โ daemon reply withinWORKSPACE_TIMEOUT_MS(default 10s; 504WORKSPACE_TIMEOUT; daemonerrorarm โ 502DAEMON_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 carrycwd+title. -
chat:session.open(socket) โ acceptsdirectory?:- absent โ legacy default (agent's install directory);
- present โ
machine.baseWorkspacemust 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'scwdand sent aschat:session.start.cwd(the daemon already honorscwdfor the subprocess andsession/new).
- SDK:
updateMachine({ baseWorkspace }),listMachineWorkspace(machineId, path?),getAgentInstance(id);MachineView.baseWorkspace,AcSessionView.{cwd,title}.
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).
acpToolCallViewSchema gains (all optional, backward compatible):
-
toolName(โค128) โ the card registry key. Daemon-side extraction:update.toolCallUpdate.toolNameif an adapter provides it, else the Claude_meta.claudeCode.toolNameon thesession/updateenvelope. -
rawInput(record(string โ unknown)) โ arguments for summaries/diffs; daemon drops it ifJSON.stringifyexceeds 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) โrawOutputtext (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).
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.AppShellgains afullvariant for this route (main dropsmax-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 byopenedAtdesc showingtitle ?? 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
--signalspend, close button) + the stream + composer.
-
Left (280px): "ๆฐๅปบไผ่ฏ" button (opens the directory picker) + session
list grouped by
-
Directory picker (
components/chat/dir-picker.tsx): FormDialog (W5 primitive) + lazy tree โ root = machinebaseWorkspace, children fetched vialistMachineWorkspaceon expand, directories only. IfbaseWorkspaceis 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 (statuspending|in_progressโrunning);turn_resultโ sweep + turn-tail (duration measured client-side; usage fromusageevents);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 -Rfooter), TerminalBlock (status dot +[dir] $ cmdbanner + 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).
- 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.
-
promptBlockSchemaalready acceptsresource_linkโ @-file mentions and slash commands are deferred (the adapters don't surface commands; the browser fs-browse popover is a separate channel).
- 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).
-
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-tripsbaseWorkspace. -
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).
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-serverspage route โ abypassnow rewrites it to the SPA shell (vite.config.ts). -
W6 wire:
acpToolCallViewnormalization happens daemon-side intoolCallView/buildView(packages/cli/src/daemon/chat.ts) โ kind variants likereadToolfold to the spec short form, unknown statuses are dropped (field-by-field extraction, never a whole-view fallback), andrawInputis dropped wholesale past 32 KiB (Write-style bodies). -
W6 route bug found by its own test: the daemon
errorarm 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-toolsprompt 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).
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.
#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.
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.
#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 linerelative 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.