research phase 9 portal chat ui - sinrimin/harness-nexus GitHub Wiki
Research: Phase 9 — portal session-page distillation (chat UI reference)
Status: reference studied (2026-09). Source: the SAME local copy as the C5 note (
~/acp-ref/,research-phase-8-c5-acp-web-demo.md) — this note covers the OTHER half:portal/'s session-page presentation (session list, message stream, content rendering), which W6 ports onto the Signal design system. Parent design:design-phase-9-portal-ui.md.
What the reference does (verified in source)
1. Layout & navigation — Channel > cwd-group > session
portal/src/App.tsx:
- Left rail (280px): a two-level inline menu. Top level = channels (agents);
the selected channel expands its session list grouped by
cwd— the grouping the W6 requirements ask for exists in the reference:App.tsx:335-378builds acwdMap: Map<cwd, AgentSessionInfo[]>, sessions within a group sorted by session time desc, groups sorted by their newest session. A group header renderscwdBasename(cwd)(last path segment) with the full cwd in a tooltip; a session row renderstitle || sessionId.slice(0,16)plus adayjs.fromNow()relative time. - Right pane states: no session → welcome page (hero + "新建会话" + 5 most recent sessions); active session → toolbar (channel/session tags, back / new / fork / delete / clear buttons) + message stream + composer.
- New session = pick a directory first.
DirPickerModal(components/DirPickerModal.tsx): a lazy-loaded directory tree rooted atworkspaceRoot(fetched from/api/config), directories only; the confirm button resolves the selection (creating the dir if missing) and callssession/newwith that cwd. This is exactly our "base workspace + subdirectories only" requirement — minus that their workspace root is the Portal server's own fs, while ours must be listed through the daemon on the user's machine.
2. Message stream — a row sequence, not a bubble list
portal/src/model/types.ts + model/fold.ts (pure reducer, O(1) hot path):
ConversationRow =
| user { text }
| assistant { step: AssistantStep } // one segment of model output
| tool { root: ToolCallNode } // one tool call, lifecycle
| system { text, tone }
| turn-tail { stats }
Formation rules (the load-bearing part):
turn_beginpushes an empty running assistant step ("思考中…" placeholder until the first chunk).- Text/thought chunks append to the current step's last same-type block (
appendBlockconcat). tool_startpushes an independent tool row after the current step AND immediately settles that step — a later text chunk then opens a NEW step. This is what preserves order-feel (text → tool → text reads as three rows).turn_settledsweeps ALL running steps/tools (cancelled/error → the current step getsinterrupted, otherssettled; still-running tools converge tocompleted), appends aturn-tailstats row, and is idempotent (a tail already at the end ⇒ no-op).- Orphan
tool_updatefor an unknown callId is silently ignored (protocol guaranteestool_callfirst).
AssistantStep.status: running | settled | interrupted; only the last block of a RUNNING step renders in streaming mode (cursor / follow-tail).
3. Rendering per row (components/stream/*)
- user: right-aligned bubble (max-width ~82%, pre-wrap).
- assistant: document-flow (no bubble).
text→ markdown with streaming cursor on the last live block;reasoning→ ReasoningRow: a 24px-high disclosure row ("思考" + one-line summary). While running the summary shows the LAST line and horizontally follows the stream; when settled it shows the FIRST line; expanding reveals the full grey text. Empty+running ⇒ synthesized "思考中…" placeholder.interrupted⇒ a quiet "已停止" inline tag. - tool: ToolCallTree — a disclosure card resolved through a three-tier
registry: exact
toolName(Read/Bash/Edit/Write/Grep/Glob/WebSearch/ WebFetch/Task/Agent/TodoWrite…) → ACPkind(read/edit/execute/search/ fetch) →GenericToolCardfallback, each wrapped in an error boundary ("never crash" discipline). Collapsed row:[icon] ToolName · summary(file tools show the cwd-relative path, Bash shows description-or-command); failed ⇒ red StateDot + error-first-line in error color. Expanded body = the Block family:ReadBlock— banner (relative path · "显示 N / M 行" · lang tag · copy) + line-numbered syntax-highlighted window, head/tail capped (8 lines) with "… 其余 N 行" in-place expand.DiffBlock— per-file path header +-/+lines in pure text colors (no row backgrounds),white-space: pre,└ +A -R · N filesfooter, head/tail cap + copy.TerminalBlock— status dot +[cwd-basename] $ commandbanner (+ exit-code pill),preoutput capped ~224px with follow-tail while running.SearchBlock— grep matches grouped by file (collapsible headers,lineNo: line) or a flat path list; counts + copy.IoCard— IN/OUT slot-label card for everything else (Task prompt / generic rawInput JSON + rawOutput).TodoCard— TodoWrite rendered as a live checklist (non-collapsible).
- system: one-line note, tone-colored.
- turn-tail: one muted small line — duration ·
↑in ↓out tokens· "已取消" — nothing when empty.
4. Scroll policy (ChatStream.tsx)
Stick-to-bottom only when the user is already at the bottom (80px threshold);
a programmatic-scroll flag suppresses the "left bottom" misjudgment between
scroll frames; a ResizeObserver on the content keeps following late height
growth (images, lazy diagrams, highlight); session load (empty→non-empty)
jumps to bottom + one 400ms catch-up tick; a floating "回到底部" button
appears when scrolled up.
5. Composer (ChatInput.tsx)
Enter sends / Shift+Enter newline; send vs stop button swaps on busy;
/ slash-command palette and @ file mentions (via a fs-browsing popover)
— both depend on capabilities the reference's Portal/agent expose but our
v1 wire does not (see "deferred").
What W6 adopts vs adapts
| Reference | W6 decision |
|---|---|
| antd Menu + inline styles + dsh tokens | Signal system: tailwind classes, bg-background/text-muted-foreground etc., IBM Plex Mono for paths/IDs |
@ant-design/x-markdown + react-syntax-highlighter |
react-markdown + remark-gfm + rehype-highlight (highlight.js) — one markdown stack, self-hosted, no CDN |
| MermaidBlock, IdePanel, terminal, @-mentions, slash | deferred (out of scope; our adapters don't surface available_commands, and IDE/terminal are separate channels) |
Session list from agent-side session/list (resumable) |
Our AcSession audit rows (cwd/title/openedAt) — no resume in v1: closed rows render greyed; open rows clickable |
| DirPicker over Portal-local fs | DirPicker over daemon-routed workspace:list (subdirectories of the machine's baseWorkspace) |
| fold fed by raw ACP | fold fed by our semantic chat:event stream (same row model; permissions stay a separate inline slice) |
toolName from _meta.claudeCode.toolName |
Same extraction, daemon-side, extended into acpToolCallView (toolName/rawInput/content/output) |
Numbers worth keeping
- Row chrome: 24px-high disclosure rows, 16px icon slot, 14px title, summary tertiary + ellipsis; 16px vertical rhythm between rows.
- Head/tail cap: 8 lines (Read/Diff/Search); Terminal caps by height.
- Content column max width ~748px; user bubble max-width min(525px, 82%).
- Copy buttons settle to "已复制" for 1s (
useCopyFeedback). - Shimmer sweep 300px/2.6s for running rows (we use a subtler pulse — Signal).
Out of scope of this note
- The protocol/bridge half (C5 note covers it) and dsh-specific plugin mechanics (T1 note).
- Running the demo here (source-reading only, same as C5).