feat(frontend): chat shell with mock WS + rAF-batched streaming (M6.5) - #84
Conversation
First consumer of the M6.4 WebSocket factory: a full ChatPanel mounted in the app drawer, wired against a mock transport that is isomorphic to `createChatWsClient` for a trivial swap in M7.4. Library: - `lib/mockChat.ts` — `createMockChatClient(opts)` with the exact signature of `createChatWsClient<ChatMap>` (url / onEvent / onOpen / onClose / onError / signal). Five demo scenarios routed by a simple prompt pattern match: default streaming, markdown table, SQL code block, `query_kb` tool call, Sentinel → Investigator handoff. Streaming is cumulative setTimeout (~22ms/token) with jitter. Chat feature (`src/app/chat/*`): - `chatStore.ts` — Zustand store. Normalized `UserMessage | AgentMessage`, parts as `(TextPart | ToolCallPart | HandoffPart)[]` with text fusion on consecutive `text_delta` and auto-sealing on tool_call / handoff boundaries. Actions: connect / disconnect / sendMessage / reset / requestFocus. - `useThrottledMessages.ts` — **rAF coalescing** hook: subscribes to the raw store but only commits to React once per animation frame. Eliminates the per-character re-render footgun under 60+ tokens/s. - `useAutoScroll.ts` — smart auto-scroll. Pauses when the user scrolls up beyond a 48px threshold, exposes a `pendingCount` badge and a smooth `jumpToBottom` to re-engage. - `Markdown.tsx` — `react-markdown` + `remark-gfm` wrapper with custom components tokenised end-to-end (no raw slate, no inline hex, no shadow). Covers p / h1-h3 / a / ul/ol/li / blockquote / hr / table+thead+th+td / pre / inline code / block code. - `Message.tsx` — `UserRow` (bubble bg-elevated, right-aligned, max-w-80%) vs `AgentRow` (no bubble — colored agent `Badge` over plain text). `ToolCallRow` (muted row, `Activity` pulse running / `Check` nominal done, mono tool name, args truncated, summary). `HandoffRow` (dashed border, `Badge from → Badge to`). Blinking caret on streaming text parts. `memo` with bucket-timestamp comparator to skip re-render on minute-tick. - `MessageList.tsx` — `role="log" aria-live="polite"`, empty state text-first (§5.5 compliant). Floating "Jump to latest" button with pending count and smooth restore. Timestamp bucket refresh every 30s without re-rendering history. - `ChatInput.tsx` — auto-resize textarea (max 8 rows), Enter submits / Shift+Enter newline (with IME `isComposing` guard), accent submit button, `forwardRef`/`useImperativeHandle` for external focus. - `ChatPanel.tsx` — assembles ConnectionIndicator + MessageList + ChatInput. Reacts to `focusRequestId` from the store. Integration: - `Drawer.tsx` — wrapper switched from `flex-1 overflow-auto` to `flex min-h-0 flex-1 flex-col` so ChatPanel owns its own scroll. - `AppShell.tsx` — Cmd+K extended: if drawer open → `requestFocus()`, else → toggle + focus after 240ms (drawer slide duration). `drawerOpenRef` reads the current state without re-binding the handler. Scope OUT (per spec, deferred): - `thinking_delta` events are currently no-op in the store (inline thinking UI lands in M8.5). - `ui_render` events are currently no-op (artifact dispatch lands in M7.5 + M8.x). - No virtualisation yet — memo + rAF holds up to ~100 messages comfortably. To revisit if demo pushes >500. All quality gates green: - typecheck ✓ - build ✓ (645.38 kB / 200.57 kB gzip — +54.69 kB gz from the previously unused react-markdown + remark-gfm chain) - check (biome) ✓ - test ✓ (9/9 passing, unchanged) No new deps — react-markdown, remark-gfm, zustand, framer-motion were already installed. The API is isomorphic to `createChatWsClient`: in M7.4 the swap is one import line + one send-format tweak. Closes #38
There was a problem hiding this comment.
Pull request overview
Adds an initial chat experience to the frontend app drawer, wired to a mock WebSocket transport and optimized for high-frequency streaming updates (rAF batching + smart auto-scroll), intended to be swappable to the real /api/v1/agent/chat client later.
Changes:
- Introduces a mock chat transport (
createMockChatClient) that streamsChatMapevents for several demo scenarios. - Adds a Zustand chat store plus chat UI components (panel, list, message rendering, markdown, input) with streaming support.
- Integrates ChatPanel into the app drawer and extends Cmd/Ctrl+K to focus the chat input (and open the drawer if closed).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/lib/mockChat.ts | Mock chat transport emitting ChatMap streaming events via prompt-based scenarios. |
| frontend/src/app/chat/chatStore.ts | Zustand store for chat messages + connection lifecycle + streaming event handling. |
| frontend/src/app/chat/useThrottledMessages.ts | rAF-batched subscription to reduce render frequency during token streaming. |
| frontend/src/app/chat/useAutoScroll.ts | Smart auto-scroll with pause-on-scroll-up and pending-message badge support. |
| frontend/src/app/chat/MessageList.tsx | Scroll container + “Jump to latest” UI + timestamp tick wiring. |
| frontend/src/app/chat/Message.tsx | Message rendering (user bubble, agent rows, tool calls, handoffs, streaming caret). |
| frontend/src/app/chat/Markdown.tsx | react-markdown + remark-gfm rendering with styled components. |
| frontend/src/app/chat/ChatInput.tsx | Auto-resizing textarea with Enter-to-send + IME guard + submit button. |
| frontend/src/app/chat/ChatPanel.tsx | Composes indicator + list + input; connects on mount; focuses on request id. |
| frontend/src/app/Drawer.tsx | Adjusts drawer child layout so ChatPanel owns internal scrolling. |
| frontend/src/app/AppShell.tsx | Mounts ChatPanel in Drawer and updates Cmd/Ctrl+K behavior to focus it. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Drain after OPEN — mock opens on next microtask; guard with a tiny retry. | ||
| const dispatch = () => { | ||
| if (internal.handle && get().status === "open") { | ||
| internal.handle.sendMock(trimmed); | ||
| } else { | ||
| queueMicrotask(dispatch); | ||
| } |
There was a problem hiding this comment.
sendMessage() uses queueMicrotask(dispatch) to wait for status === "open", but the mock client calls onOpen via setTimeout(..., 0). That means if a message is sent before the open callback runs, this can spin an unbounded microtask loop and starve the event loop (the setTimeout never gets a chance to fire). Use a macrotask-based retry (e.g. setTimeout(0) / requestAnimationFrame) and/or add a retry cap, or make the mock (and real client) open on a microtask so the wait loop always yields.
| // Drain after OPEN — mock opens on next microtask; guard with a tiny retry. | |
| const dispatch = () => { | |
| if (internal.handle && get().status === "open") { | |
| internal.handle.sendMock(trimmed); | |
| } else { | |
| queueMicrotask(dispatch); | |
| } | |
| // Drain after OPEN. Use a macrotask retry so the event loop can process | |
| // the async open callback, and cap retries to avoid unbounded polling. | |
| let attempts = 0; | |
| const maxAttempts = 50; | |
| const dispatch = () => { | |
| if (internal.handle && get().status === "open") { | |
| internal.handle.sendMock(trimmed); | |
| return; | |
| } | |
| if (!internal.handle || get().status === "closed" || get().status === "error") { | |
| return; | |
| } | |
| if (attempts >= maxAttempts) { | |
| return; | |
| } | |
| attempts += 1; | |
| setTimeout(dispatch, 0); |
| internal.handle = createMockChatClient({ | ||
| url: "/api/v1/agent/chat", | ||
| onOpen: () => set({ status: "open", error: null }), | ||
| onClose: () => set({ status: "closed" }), |
There was a problem hiding this comment.
ensureConnected() sets internal.handle once and never clears it on socket close. If the transport closes unexpectedly (non-user initiated), status becomes "closed" but connect() will no-op forever because internal.handle is still non-null. Update the onClose handler to also null out internal.handle (and likely internal.currentAgentMessageId) so reconnecting works.
| onClose: () => set({ status: "closed" }), | |
| onClose: () => { | |
| internal.handle = null; | |
| internal.currentAgentMessageId = null; | |
| set({ status: "closed" }); | |
| }, |
| export const Message = memo(MessageImpl, (prev, next) => { | ||
| if (prev.message !== next.message) return false; | ||
| // Relative timestamp re-renders on parent tick; suppress if within the same bucket. | ||
| const prevBucket = Math.floor((prev.now - prev.message.createdAt) / 60_000); | ||
| const nextBucket = Math.floor((next.now - next.message.createdAt) / 60_000); | ||
| return prevBucket === nextBucket; |
There was a problem hiding this comment.
The Message memo comparator buckets by minutes only, but formatRelativeTime() changes its output at 30s ("just now" → "Ns ago"). With the current comparator, messages in the first minute will not re-render on the 30s tick, so they can incorrectly stay as "just now" until 1 minute passes. Consider bucketing at 30s for the first minute (or comparing the rendered relative-time string) so the UI updates when the label changes.
| thead: ({ children, ...rest }) => ( | ||
| <thead {...rest} className="bg-[var(--ds-bg-elevated)] text-left"> | ||
| {children} | ||
| </thead> | ||
| ), | ||
| th: ({ children, ...rest }) => ( | ||
| <th | ||
| {...rest} | ||
| className="border-b border-[var(--ds-border)] px-2.5 py-1.5 font-medium text-[var(--ds-fg-muted)]" | ||
| > | ||
| {children} | ||
| </th> | ||
| ), |
There was a problem hiding this comment.
PR description mentions markdown tables with a sticky header, but the custom table components don’t apply any sticky positioning to <thead>/<th>. If sticky headers are part of the acceptance criteria, add the needed position: sticky + top: 0 (and appropriate background/z-index) styles for header cells, or update the description if it’s intentionally deferred.
| const notifyContentGrew = useCallback(() => { | ||
| if (pausedRef.current) return; | ||
| // Defer until layout has settled so scrollHeight reflects the new content. | ||
| requestAnimationFrame(() => scrollToBottom(false)); | ||
| }, [scrollToBottom]); | ||
|
|
||
| const notifyMessageAppended = useCallback(() => { | ||
| if (pausedRef.current) { | ||
| setPendingCount((c) => c + 1); | ||
| return; | ||
| } | ||
| requestAnimationFrame(() => scrollToBottom(false)); | ||
| }, [scrollToBottom]); |
There was a problem hiding this comment.
notifyContentGrew() / notifyMessageAppended() schedule a new requestAnimationFrame on every call without coalescing. During streaming, these can be invoked once per frame (or more) and queue redundant rAF callbacks that all call scrollToBottom, which can cause unnecessary work/jank. Consider tracking a pending rAF id (similar to the scroll handler) so you schedule at most one scroll-to-bottom per frame.
| sendMessage: (content) => { | ||
| const trimmed = content.trim(); | ||
| if (!trimmed) return; | ||
|
|
||
| ensureConnected(); | ||
|
|
||
| const userMsg: UserMessage = { | ||
| id: nextId(internal, "u"), | ||
| role: "user", | ||
| content: trimmed, | ||
| createdAt: Date.now(), | ||
| }; | ||
| const agentMsg: AgentMessage = { | ||
| id: nextId(internal, "a"), | ||
| role: "agent", | ||
| agent: DEFAULT_AGENT, | ||
| parts: [], | ||
| createdAt: Date.now() + 1, | ||
| streaming: true, | ||
| }; | ||
| internal.currentAgentMessageId = agentMsg.id; | ||
|
|
||
| set((state) => ({ messages: [...state.messages, userMsg, agentMsg] })); |
There was a problem hiding this comment.
sendMessage() overwrites internal.currentAgentMessageId unconditionally, so if the user submits another prompt while a previous response is still streaming, subsequent incoming frames will be attributed to the newest agent message and interleave/mix content. If the chat protocol doesn’t support parallel turns yet (no turn_id in ChatMap), consider blocking/queuing sends while an agent message is streaming (and/or disabling the input) to avoid corrupting the transcript.
|
|
||
| if (lower.includes("table") || lower.includes("wo") || lower.includes("work order")) { | ||
| return buildTableScenario(); | ||
| } | ||
| if (lower.includes("code") || lower.includes("sql") || lower.includes("query")) { | ||
| return buildCodeScenario(); | ||
| } | ||
| if (lower.includes("tool") || lower.includes("query_kb") || lower.includes("search")) { | ||
| return buildToolCallScenario(prompt); | ||
| } |
There was a problem hiding this comment.
buildScenario() prompt routing is overly broad and the condition order makes the documented demo prompts pick the wrong scenario. lower.includes("wo") will match common words like "show" and route to the table scenario, and the code/sql/query branch runs before the tool/query_kb/search branch so prompts like query_kb ... will hit the code scenario first. Tighten the match for work orders (e.g., word boundary / "wo-" / "work order") and reorder or narrow the query check so query_kb reliably selects the tool-call scenario.
| if (lower.includes("table") || lower.includes("wo") || lower.includes("work order")) { | |
| return buildTableScenario(); | |
| } | |
| if (lower.includes("code") || lower.includes("sql") || lower.includes("query")) { | |
| return buildCodeScenario(); | |
| } | |
| if (lower.includes("tool") || lower.includes("query_kb") || lower.includes("search")) { | |
| return buildToolCallScenario(prompt); | |
| } | |
| const isWorkOrderPrompt = | |
| lower.includes("table") || lower.includes("work order") || lower.includes("wo-") || /\bwo\b/.test(lower); | |
| const isToolPrompt = lower.includes("tool") || lower.includes("query_kb") || lower.includes("search"); | |
| const isCodePrompt = lower.includes("code") || lower.includes("sql") || lower.includes("query"); | |
| if (isWorkOrderPrompt) { | |
| return buildTableScenario(); | |
| } | |
| if (isToolPrompt) { | |
| return buildToolCallScenario(prompt); | |
| } | |
| if (isCodePrompt) { | |
| return buildCodeScenario(); | |
| } |
Compact the trailing space in ChatInput to eliminate the visual gap between the hint line and the drawer bottom. - form: py-3 → pt-2.5 pb-2 (save ~14px vertical) - hint: mt-1.5 → mt-1 (save 2px between input and hint)
Move the 'Enter to send · Shift + Enter for new line' hint from below the bordered input box to inside it. Unifies the block visually and removes the empty space that remained at the bottom of the drawer.
Summary
First visual consumer of the M6.4 WebSocket factory. A full ChatPanel mounts in the app drawer, wired against a mock transport whose API is isomorphic to
createChatWsClient— the swap to the real/api/v1/agent/chatendpoint in M7.4 will be one import line.Architecture
lib/mockChat.ts— 5 demo scenarios routed by prompt-pattern match: default streaming / markdown table / SQL code block /query_kbtool call / Sentinel→Investigator handoff. Cumulative setTimeout (~22ms/token) with jitter.app/chat/chatStore.ts— Zustand store with normalizedUserMessage | AgentMessage, parts as(TextPart | ToolCallPart | HandoffPart)[], auto-seal on tool/handoff boundaries, text fusion on consecutivetext_delta.app/chat/useThrottledMessages.ts— rAF coalescing: store emits 60+ text_delta/s, React only sees one commit per animation frame. Kills the per-character re-render footgun.app/chat/useAutoScroll.ts— pauses auto-scroll when user scrolls up >48px, exposes a pending count and smoothjumpToBottom.app/chat/Markdown.tsx—react-markdown+remark-gfmwith custom components tokenised end-to-end (p / h1-h3 / a / lists / blockquote / hr / table / pre / inline and block code). No raw slate, no inline hex.app/chat/Message.tsx— user bubble (right, bg-elevated, max-w-80%) / agent row (no bubble, agent Badge in agent color + plain text). Tool calls and handoffs as inline rows. Streaming caret.memowith bucket-timestamp comparator to skip re-render on tick.app/chat/MessageList.tsx—role="log" aria-live="polite", empty state text-first, floating "Jump to latest" with pending badge.app/chat/ChatInput.tsx— auto-resize textarea (max 8 rows), Enter submit / Shift+Enter newline (with IMEisComposingguard),forwardReffor Cmd+K focus.app/chat/ChatPanel.tsx— connection indicator + list + input, reacts tofocusRequestId.Integration
Drawer.tsxwrapper:flex-1 overflow-auto→flex min-h-0 flex-1 flex-colso ChatPanel owns scroll.AppShell.tsxCmd+K extended: drawer open →requestFocus(); drawer closed → toggle + focus after 240ms (slide duration).Scope OUT (per spec, deferred)
thinking_delta→ no-op (inline thinking UI lands M8.5)ui_render→ no-op (artifact dispatch lands M7.5 + M8.x)Acceptance (#38)
--ds-agent-*Cmd/Ctrl+Kfocuses input (also toggles drawer if closed)Test plan — how to test on your PC
Open the chat drawer (right side or
Cmd+K) and try these prompts :hello(anything generic)show me the table(includes "table")give me SQL(includes "code" or "sql")signals.flow_ratequery_kb for P-02(includes "tool" or "search")handoff to investigator(includes "handoff")Stress tests :
Gates (all green) :
npm run typechecknpm run build(645 kB / 200 kB gz, +55 kB gz from react-markdown chain — previously installed but unused)npm run check(Biome)npm run test(9/9, unchanged)Swap to real WS (M7.4)
Two trivial changes in
chatStore.ts::ensureConnected:Plus one tweak in
sendMessage(replacehandle.sendMock(prompt)with the real send-format agreed with zestones — probablyJSON.stringify({prompt})on the raw socket or a helper onWsClient).Everything else stays: store, components, rAF batching, auto-scroll, Cmd+K. Zero mock leakage outside
mockChat.tsandensureConnected().Closes #38