Skip to content

feat(frontend): chat shell with mock WS + rAF-batched streaming (M6.5) - #84

Merged
vgtray merged 3 commits into
mainfrom
feat/38-chat-shell-mocked
Apr 22, 2026
Merged

feat(frontend): chat shell with mock WS + rAF-batched streaming (M6.5)#84
vgtray merged 3 commits into
mainfrom
feat/38-chat-shell-mocked

Conversation

@vgtray

@vgtray vgtray commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

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/chat endpoint 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_kb tool call / Sentinel→Investigator handoff. Cumulative setTimeout (~22ms/token) with jitter.
  • app/chat/chatStore.ts — Zustand store with normalized UserMessage | AgentMessage, parts as (TextPart | ToolCallPart | HandoffPart)[], auto-seal on tool/handoff boundaries, text fusion on consecutive text_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 smooth jumpToBottom.
  • app/chat/Markdown.tsxreact-markdown + remark-gfm with 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. memo with bucket-timestamp comparator to skip re-render on tick.
  • app/chat/MessageList.tsxrole="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 IME isComposing guard), forwardRef for Cmd+K focus.
  • app/chat/ChatPanel.tsx — connection indicator + list + input, reacts to focusRequestId.

Integration

  • Drawer.tsx wrapper: flex-1 overflow-autoflex min-h-0 flex-1 flex-col so ChatPanel owns scroll.
  • AppShell.tsx Cmd+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)
  • No virtualisation (memo + rAF holds ~100 messages easily, revisit if demo pushes >500)

Acceptance (#38)

  • Message sent → response streams with realistic delays
  • Markdown tables rendered (sticky header, hairlines, overflow-x)
  • Auto-scroll follows bottom unless user has scrolled up (pending badge)
  • Colored agent badge via --ds-agent-*
  • Cmd/Ctrl+K focuses input (also toggles drawer if closed)

Test plan — how to test on your PC

git fetch
git checkout feat/38-chat-shell-mocked
git pull
make up  # Docker — sinon proxy 500 hors container
# → http://localhost:5173 → login admin/admin123

Open the chat drawer (right side or Cmd+K) and try these prompts :

Prompt What you should see
hello (anything generic) Streaming text response with caret, ~80 tokens
show me the table (includes "table") Markdown table with 3 rows, sticky header
give me SQL (includes "code" or "sql") Code block with syntax highlighting tokens + inline signals.flow_rate
query_kb for P-02 (includes "tool" or "search") Tool call row with Activity pulse → Check when done
handoff to investigator (includes "handoff") Dashed row Sentinel→Investigator badge

Stress tests :

  • Auto-scroll smart : pendant un stream long, scroll up ≥48px → stream continue, "Jump to latest" apparaît avec badge count. Click = smooth return.
  • Cmd+K : drawer fermé → ouvre + focus input ; drawer ouvert → focus instant ; déjà dans un input ailleurs → bail-out.
  • Theme switch : toggle System/Dark/Light pendant un stream, tous les composants suivent.
  • Timestamps : attendre 30s après un message → "just now" devient "Xs ago" sans re-render de l'historique.

Gates (all green) :

  • npm run typecheck
  • npm 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 :

- import { createMockChatClient } from "../../lib/mockChat"
+ import { createChatWsClient } from "../../lib/ws"

- const handle = createMockChatClient({ ... })
+ const handle = createChatWsClient<ChatMap>({ url: "/api/v1/agent/chat", ... })

Plus one tweak in sendMessage (replace handle.sendMock(prompt) with the real send-format agreed with zestones — probably JSON.stringify({prompt}) on the raw socket or a helper on WsClient).

Everything else stays: store, components, rAF batching, auto-scroll, Cmd+K. Zero mock leakage outside mockChat.ts and ensureConnected().

Closes #38

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
Copilot AI review requested due to automatic review settings April 22, 2026 22:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 streams ChatMap events 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.

Comment on lines +258 to +264
// 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);
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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);

Copilot uses AI. Check for mistakes.
internal.handle = createMockChatClient({
url: "/api/v1/agent/chat",
onOpen: () => set({ status: "open", error: null }),
onClose: () => set({ status: "closed" }),

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
onClose: () => set({ status: "closed" }),
onClose: () => {
internal.handle = null;
internal.currentAgentMessageId = null;
set({ status: "closed" });
},

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +178
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;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +116
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>
),

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +58
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]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +234 to +256
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] }));

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +49

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);
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
}

Copilot uses AI. Check for mistakes.
vgtray added 2 commits April 23, 2026 00:12
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.
@vgtray
vgtray merged commit 62841cc into main Apr 22, 2026
9 checks passed
@vgtray
vgtray deleted the feat/38-chat-shell-mocked branch April 22, 2026 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M6.5 — Chat shell avec WS mocké

2 participants