---
phase: 22
slug: agent-streaming
status: draft
shadcn_initialized: true
preset: new-york / neutral / css-variables
created: 2026-04-01
---

# Phase 22 — UI Design Contract

> Visual and interaction contract for Phase 22: Agent Streaming.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

---

## Design System

| Property | Value | Source |
|----------|-------|--------|
| Tool | shadcn/ui | `ui/components.json` — unchanged from Phase 21 |
| Style | new-york | `ui/components.json` |
| Base color | neutral | `ui/components.json` |
| CSS variables | true | `ui/components.json` |
| Component library | Radix UI (via shadcn new-york) | `ui/components.json` |
| Icon library | lucide-react ^0.574.0 | `ui/components.json` |
| Font | System UI (`font-sans`, inherited) | `ui/src/index.css` |

**Existing shadcn components available (no install needed):**
`avatar`, `badge`, `button`, `card`, `checkbox`, `collapsible`, `command`, `dialog`, `dropdown-menu`, `input`, `label`, `popover`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `tabs`, `textarea`, `tooltip`

**Existing custom components to reuse/extend:**
- `ChatPanel.tsx` — extend with agent selector header area and streaming state; do not restructure the two-column layout
- `ChatMessage.tsx` — extend props to accept `agentId`, `agentName`, `agentIcon` for identity rendering
- `ChatMessageList.tsx` — replace with virtualized list (`@tanstack/react-virtual`) for PERF-03
- `ChatInput.tsx` — extend with slash command popover and @mention popover (reuse `MentionOption` pattern from `MarkdownEditor.tsx`)
- `AgentIcon` (from `AgentIconPicker.tsx`) — reuse directly for agent avatar rendering in messages
- `agentStatusDot` (from `status-colors.ts`) — reuse `running: "bg-cyan-400 animate-pulse"` for streaming indicator

**New package required:**
- `@tanstack/react-virtual` — not currently installed; add to `ui/package.json` for PERF-03 (1,000+ message virtualization)

---

## Layout Contract

### Layout Unchanged

The overall layout established in Phase 21 is unchanged:

```
[ CompanyRail ] [ Sidebar ] [ <main> ] [ ChatPanel ] [ PropertiesPanel ]
```

Phase 22 adds new UI surfaces **inside** `ChatPanel` only. No layout-level changes.

### ChatPanel Header — Agent Selector

The `ChatPanel` header row gains an agent selector to the left of the close button:

```
[ "Chat" label ]  [ AgentSelector dropdown ]  [ ─── spacer ─── ]  [ X close ]
```

- The `AgentSelector` is a `<Select>` or `<Popover>` trigger showing the active agent's icon + name
- Width: fit-content, max 120px, truncated with ellipsis
- Placement: `flex items-center gap-2` row, same `px-4 py-2` padding as Phase 21 header
- When no agent is selected, label reads "Select agent" in `text-muted-foreground`

### Message Bubble — Agent Identity Bar

Every assistant message gains an identity bar above the message content:

```
[ AgentIcon 16px ]  [ agentName 13px semibold ]  [ timestamp 11px muted ]
```

- Identity bar: `flex items-center gap-2 mb-1`
- Icon: 16×16px, rendered via `<AgentIcon icon={agentIcon} className="h-4 w-4" />`
- Name: `text-[13px] font-semibold text-foreground`
- Timestamp: `text-[11px] text-muted-foreground`
- No background, no border — identity floats above message content

### Streaming Cursor

A blinking block cursor appended to the last streamed token while generation is active:

- Element: `<span className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom" />`
- Animation: `animate-cursor-blink` — declare in `index.css` (see Animation section)
- Rendered only when `isStreaming: true` on the message; removed when stream ends

### Stop Button

While a response is streaming, a Stop button appears below the message thread, centered above `ChatInput`:

```
[ (─────────────) ]   ← centered row
[  ■ Stop generating ]  ← Button variant="outline" size="sm"
[ (─────────────) ]
[ ChatInput ]
```

- Container: `flex justify-center py-2 border-t border-border`
- Button: `variant="outline" size="sm"`, icon `Square` (filled stop), label "Stop generating"
- Disappears immediately on click (optimistic) before server confirms cancellation

### Edit / Retry Controls

User messages gain an edit pencil on hover. Assistant messages gain a retry button on hover.

**User message edit:**
- `Pencil` icon button, 14×14px, `variant="ghost" size="icon"`, positioned at top-right of message bubble
- Visible only on `group-hover`; always visible on touch devices
- Click → switches message bubble to inline edit mode (see Interaction Contract)

**Assistant message retry:**
- `RefreshCw` icon button, 14×14px, `variant="ghost" size="icon"`, positioned below message content, right-aligned
- Visible only on `group-hover`; always visible on touch devices
- Only present on messages where `isStreaming: false` and `role === "assistant"`

### Slash Command Popover

When the user types `/` as the first character in `ChatInput`:

```
[ /brainstorm  — Route to Brainstormer    ]  ← highlighted item
[ /ask-pm      — Route to PM              ]
[ /ask-engineer — Route to Engineer       ]
[ /task        — Create a task            ]
[ /search      — Search conversations     ]
```

- Container: shadcn `<Popover>` anchored to the top-left of the textarea, opens upward
- List: `<Command>` component inside popover (reuses existing `cmdk` install)
- Width: 260px, max 5 items visible before scroll
- Dismiss: Escape, clicking outside, or completing the command

### @Mention Popover

When the user types `@` in `ChatInput`, an agent autocomplete popover appears. Reuses the `MentionOption` pattern from `MarkdownEditor.tsx`:

- Container: `<Popover>` anchored to the `@` character position, opens upward
- List: agent names filtered by query string after `@`
- Each row: `<AgentIcon>` 14×14px + `agentName` text + `role` label in muted text
- Width: 200px, max 5 agents before scroll
- Selection: click or Enter inserts `@agentName` token into textarea and routes message to that agent

---

## Spacing Scale

Inherited from Phase 21. No new tokens for Phase 22.

| Token | Value | Phase 22 Usage |
|-------|-------|----------------|
| xs | 4px | Icon gaps in identity bar (`gap-1`) |
| sm | 8px | Stop button padding, retry button margin |
| md | 16px | Panel padding (unchanged) |
| lg | 24px | (no new usage) |
| xl | 32px | (no new usage) |

**New spacing values (Phase 22 only):**
- Identity bar gap: `gap-2` (8px) — between icon and agent name; matches existing `gap-2` pattern in ChatPanel header
- Edit/retry icon button size: 14×14px (`h-3.5 w-3.5`) — matches `MoreHorizontal` in `ChatConversationItem`
- Streaming cursor width: `w-2` (8px), height: `h-[1em]`

---

## Typography

All inherited from Phase 21. One addition for agent identity:

| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Body / message text | 15px (0.9375rem) | 400 | 1.6 | Chat message prose (unchanged) |
| Label / UI chrome | 13px (0.8125rem) | 400 | 1.4 | Conversation list, timestamps, slash command descriptions (unchanged) |
| Agent name (identity bar) | 13px (0.8125rem) | 600 | 1.4 | Agent name above assistant messages |
| Message timestamp | 11px (0.6875rem) | 400 | 1.4 | Timestamp in identity bar |

**Weights used:** 400 (regular) and 600 (semibold). No additional weights. Same constraint as Phase 21.

**Slash command descriptions:** Rendered at 13px / 400 with `text-muted-foreground` — color/opacity distinction from the 13px / 400 command label is sufficient; no separate size needed. This keeps the total distinct sizes at 4 (11px, 13px, 15px, and 20px from Phase 21 markdown headings).

**11px timestamp note:** This is a 2px reduction below the Phase 21 minimum of 13px, used only within the compact identity bar where spatial context is sufficient. It is never used for interactive or error text.

---

## Color

All values inherited from Phase 21 CSS variable system. No new color variables introduced.

| Role | Catppuccin Mocha | Tokyo Night | Catppuccin Latte | Phase 22 Usage |
|------|-----------------|-------------|-----------------|----------------|
| Dominant (60%) | `--background` #1e1e2e | `--background` #1a1b26 | `--background` #eff1f5 | Unchanged |
| Secondary (30%) | `--card` #181825 | `--card` #16161e | `--card` #e6e9ef | Unchanged |
| Accent (10%) | `--accent` #45475a | `--accent` #3b4261 | `--accent` #bcc0cc | Hovered rows (unchanged) |
| Primary | `--primary` | `--primary` | `--primary` | Agent selector focus ring, send button |
| Destructive | `--destructive` | `--destructive` | `--destructive` | Not used in Phase 22 |
| Muted text | `--muted-foreground` | `--muted-foreground` | `--muted-foreground` | Timestamps, role labels, empty states |

**Accent reserved for (Phase 22 additions):**
- Same as Phase 21 (conversation rows, code block toolbar)
- Slash command popover highlighted item: `bg-accent` (via `<Command>` item selection)

**Streaming indicator color:** `bg-cyan-400 animate-pulse` — reused directly from `agentStatusDot.running` in `status-colors.ts`. Applied as a 6×6px dot beside the agent name in the identity bar while streaming. Note: `bg-cyan-400` bypasses the CSS variable system. If theming requirements evolve (e.g. a theme requires a different streaming color), promote this to a semantic token such as `--streaming-indicator` in the CSS variable layer.

### Agent Role Colors (THEME-03)

Agent avatars are distinguishable across all three themes using semantic role color tokens. These use Tailwind semantic colors with `dark:` variants — no new CSS variables needed.

| Role | Icon color (light theme) | Icon color (dark theme) | Rationale |
|------|--------------------------|-------------------------|-----------|
| `pm` | `text-blue-600` | `text-blue-400` | Matches `todo` status color (existing pattern) |
| `engineer` | `text-violet-600` | `text-violet-400` | Matches `in_review` status color (existing pattern) |
| `ceo` / `general` | `text-yellow-600` | `text-yellow-400` | Matches `idle` agent status (existing pattern) |
| `designer` | `text-pink-600` | `text-pink-400` | New — no conflict with existing semantic |
| `qa` | `text-orange-600` | `text-orange-400` | Matches `paused` agent status (existing pattern) |
| `researcher` | `text-teal-600` | `text-teal-400` | New — no conflict with existing semantic |
| `devops` / `cto` | `text-green-600` | `text-green-400` | Matches `done` / `active` status (existing pattern) |
| `cmo` / `cfo` | `text-neutral-600` | `text-neutral-400` | Secondary/finance roles — muted |
| fallback | `text-muted-foreground` | `text-muted-foreground` | Unknown or null role |

These colors apply to the `<AgentIcon>` in the message identity bar only. The existing `status-colors.ts` patterns are NOT modified — Phase 22 adds a new `agent-role-colors.ts` utility.

---

## Component Inventory

New components to build in Phase 22:

| Component | shadcn base | Notes |
|-----------|-------------|-------|
| `ChatAgentSelector.tsx` | `select` or `popover` + `command` | Dropdown in ChatPanel header; shows active agent icon + name; lists all workspace agents |
| `ChatMessageIdentityBar.tsx` | none | Icon + name + timestamp row above assistant messages; uses `AgentIcon` |
| `ChatStreamingCursor.tsx` | none | Inline blinking cursor element; rendered conditionally during streaming |
| `ChatStopButton.tsx` | `button` | "Stop generating" button shown above input during active stream |
| `ChatMessageActions.tsx` | `button`, `tooltip` | Edit (on user messages) and Retry (on assistant messages) hover actions |
| `ChatSlashCommandPopover.tsx` | `popover`, `command` | Slash command menu anchored to textarea; 5 commands |
| `ChatMentionPopover.tsx` | `popover` | Agent @mention autocomplete anchored to @ position |
| `useStreamingChat.ts` | none | Hook managing SSE connection, token accumulation, streaming state |
| `agent-role-colors.ts` | none | Map of `AgentRole → Tailwind class string` for icon coloring |

**Existing components to modify:**

| Component | Change |
|-----------|--------|
| `ChatPanel.tsx` | Add `ChatAgentSelector` to header; add `ChatStopButton` above input when streaming |
| `ChatMessage.tsx` | Accept `agentId`, `agentName`, `agentIcon`, `agentRole`, `isStreaming` props; render `ChatMessageIdentityBar` and `ChatStreamingCursor`; wrap in `group` for hover actions |
| `ChatMessageList.tsx` | Replace div iteration with `@tanstack/react-virtual` virtualizer; pass agent identity props down to `ChatMessage` |
| `ChatInput.tsx` | Wire `ChatSlashCommandPopover` (on `/` keystroke) and `ChatMentionPopover` (on `@` keystroke); accept `disabled` prop during streaming |

**Icons (lucide-react) — Phase 22 additions:**
- `Square` — Stop generating button icon (filled stop)
- `Pencil` — Edit user message
- `RefreshCw` — Retry assistant message
- `ChevronDown` — Agent selector dropdown indicator
- `Bot` — Default agent icon fallback (already in `AGENT_ICON_NAMES`)

**All icons from Phase 21 remain unchanged.**

---

## Interaction Contract

### Streaming Response

| Interaction | Behavior |
|-------------|---------|
| User sends message | `ChatInput` disabled; `isSubmitting` spinner on send button; SSE connection opens |
| First token arrives | New assistant message appended to list; identity bar renders; `ChatStreamingCursor` shown; `ChatStopButton` appears above input |
| Tokens continue | Text accumulates character-by-character in the message; cursor stays at end; list auto-scrolls to bottom if user has not scrolled up |
| User scrolls up during stream | Auto-scroll pauses; "Jump to bottom" button appears (↓ icon, `variant="outline" size="icon-sm"`, fixed at bottom-right of message list) |
| Stream ends | `ChatStreamingCursor` removed; `ChatStopButton` removed; `ChatInput` re-enabled; message gets final `updatedAt` timestamp |
| Stream error | Streaming cursor replaced with error inline text: "Response interrupted. [Retry ↺]" — `text-destructive` with inline Retry link |

### Stop Generation

| Interaction | Behavior |
|-------------|---------|
| Click "Stop generating" | Button removed immediately (optimistic); SSE connection closed client-side; partial message preserved as-is with a `[stopped]` suffix in muted text |
| Server confirms stop | No additional UI change needed — state already settled on client |

### Message Edit (User Messages)

| Interaction | Behavior |
|-------------|---------|
| Hover user message | `Pencil` icon button appears at top-right of bubble |
| Click `Pencil` | Message bubble becomes an inline textarea pre-filled with existing content; "Save edit" and "Discard edit" buttons appear below; send button hidden |
| Edit textarea + click "Save edit" | POST updated message to server; assistant messages after this message are removed from thread (regeneration); new streaming response begins |
| Click "Discard edit" | Textarea reverts to read-only bubble; no changes |
| Save with empty content | "Save edit" button disabled when textarea is empty |

### Retry (Assistant Messages)

| Interaction | Behavior |
|-------------|---------|
| Hover assistant message | `RefreshCw` icon button appears below message, right-aligned |
| Click `RefreshCw` | Current assistant message replaced with a streaming placeholder; SSE connection opens; regenerated response streams in |
| Retry during active stream | Not available — `RefreshCw` button hidden while any stream is active |

### Agent Selector

| Interaction | Behavior |
|-------------|---------|
| Click agent selector | Popover/dropdown opens listing all workspace agents (icon + name + role label) |
| Select agent | Active agent updated for current conversation; `PATCH /conversations/:id` with `agentId`; selector shows new agent immediately (optimistic) |
| No agents available | Selector shows "No agents" in muted text; disabled state |
| Active agent deleted externally | Selector shows "Unknown agent" in muted text; conversation continues with null `agentId` until user selects another |

### Slash Commands (INPUT-05)

| Interaction | Behavior |
|-------------|---------|
| Type `/` as first char | `ChatSlashCommandPopover` opens above input listing all 5 commands |
| Type `/bra` (partial) | Popover filters to matching commands |
| Arrow keys / Tab | Navigate popover items |
| Enter or click item | Command token inserted into textarea (e.g. `/brainstorm `); popover closes; routing applied on send |
| Escape | Popover closes; typed text remains |
| Send with `/brainstorm` prefix | Routes message to Brainstormer agent regardless of active agent selector |

**Slash command routing table:**

| Command | Routes to |
|---------|-----------|
| `/brainstorm` | Brainstormer (general role with brainstormer prompt) |
| `/ask-pm` | PM role agent |
| `/ask-engineer` | Engineer role agent |
| `/task` | PM role agent (task creation intent) |
| `/search` | No-op in Phase 22 (stubbed; Phase 24 implements) |

### @Mention Routing (INPUT-06)

| Interaction | Behavior |
|-------------|---------|
| Type `@` | `ChatMentionPopover` opens listing workspace agents (icon + name + role) |
| Type `@eng` (partial) | List filters to agents whose name contains "eng" (case-insensitive) |
| Select agent | `@AgentName` token inserted; popover closes |
| Send message with `@AgentName` | Message routed to named agent for this turn only (overrides active agent selector) |
| Unknown mention | Sent as plain text; no routing override |

### Virtualized Message List (PERF-03)

- Uses `@tanstack/react-virtual` with `useVirtualizer`
- `overscan: 5` — renders 5 items above/below visible window
- Item height: estimated 80px default; dynamic measurement via `measureElement` for variable-height markdown messages
- `scrollMarginBottom: 120px` to keep new messages visible above the input area
- "Jump to bottom" appears when `scrollOffset > 200px` from bottom; clicking calls `scrollToIndex(messages.length - 1, { align: 'end' })`

---

## Copywriting Contract

All Phase 21 copy is preserved unchanged. Phase 22 additions:

| Element | Copy | Notes |
|---------|------|-------|
| Agent selector placeholder | "Select agent" | `text-muted-foreground`; shown when no agent assigned |
| Agent selector no-options | "No agents configured" | Disabled state |
| Stop button label | "Stop generating" | `variant="outline" size="sm"`; icon `Square` precedes label |
| Edit message button aria-label | "Edit message" | Icon-only; `Pencil` icon |
| Retry message button aria-label | "Retry response" | Icon-only; `RefreshCw` icon |
| Inline edit save button | "Save edit" | `variant="default" size="sm"` |
| Inline edit cancel button | "Discard edit" | `variant="ghost" size="sm"` |
| Streaming interrupted error | "Response interrupted." | Inline `text-destructive`; followed by Retry link |
| Retry inline link | "Retry" | `text-primary underline cursor-pointer` — not a `<Button>` |
| Stopped message suffix | "[stopped]" | `text-muted-foreground text-[11px] ml-1`; appended to partial message |
| Slash command: `/brainstorm` description | "Route to Brainstormer" | Second line in slash popover item; `text-muted-foreground` |
| Slash command: `/ask-pm` description | "Route to PM" | Second line; `text-muted-foreground` |
| Slash command: `/ask-engineer` description | "Route to Engineer" | Second line; `text-muted-foreground` |
| Slash command: `/task` description | "Create a task" | Second line; `text-muted-foreground` |
| Slash command: `/search` description | "Search conversations" | Second line; `text-muted-foreground`; "Coming soon" suffix in muted text; greyed out |
| Jump to bottom button aria-label | "Scroll to latest message" | Icon-only; `ArrowDown` icon |
| Input placeholder (streaming) | "Waiting for response..." | Replaces "Message your agent..." while `isStreaming: true`; textarea is disabled |

**Tone:** Direct, functional, no corporate language. Consistent with Phase 21.

---

## States and Loading

| Component | Loading state | Empty state | Error state | Streaming state |
|-----------|--------------|-------------|-------------|-----------------|
| `ChatAgentSelector` | Skeleton pill 80px wide | "No agents configured" | "Could not load agents. Refresh to try again." — popover body | n/a |
| `ChatMessage` (assistant) | n/a | n/a | Inline "Response interrupted. Retry" | Cursor blink + streaming text accumulation |
| `ChatMessageList` | 3x Skeleton rows (h-16 variable) | Inherited from Phase 21 | Inherited from Phase 21 | Auto-scroll; Stop button visible |
| `ChatInput` | n/a | n/a | Inherited from Phase 21 | `disabled`; placeholder "Waiting for response..." |
| `ChatSlashCommandPopover` | n/a | n/a | n/a | Hidden while streaming |
| `ChatMentionPopover` | Skeleton 3 rows | "No agents found" | n/a | Hidden while streaming |

**Optimistic updates:**
- Agent selector: updates conversation's `agentId` optimistically; reverts on API error with toast "Could not update agent. Try again."
- Stop: button removed immediately; error shown inline if SSE close fails silently
- Edit/Retry: new streaming message renders immediately; old messages removed optimistically

---

## Theme Integration Contract

Phase 22 extends Phase 21's zero-new-plumbing approach:

- Agent role colors use `text-{color}-600 dark:text-{color}-400` Tailwind classes — resolve correctly in all three themes via the existing `.dark` class on `<html>`
- Tokyo Night: `.dark` class is present (as established in Phase 21 ThemeContext) — dark variants apply correctly
- Streaming cursor: `bg-foreground/70` uses CSS variable — resolves to near-white in dark themes, near-black in Catppuccin Latte
- Slash command popover and mention popover: use shadcn `<Popover>` + `<Command>` which inherit `--popover`, `--popover-foreground` CSS variables — no manual theme wiring needed

**THEME-03 checklist:**
- Agent icon colors declared per-role with `dark:` variants — all three themes tested
- Streaming indicator uses `bg-cyan-400` (same across themes — sufficient contrast on all three backgrounds)
- No hardcoded hex colors introduced in Phase 22

---

## Accessibility

Inherits all Phase 21 accessibility contracts. Phase 22 additions:

| Concern | Requirement |
|---------|-------------|
| Agent selector | `aria-label="Active agent"` on trigger; dropdown items have `role="option"` |
| Streaming message | `aria-live="polite"` on `ChatMessageList` (established in Phase 21) handles streamed token announcements without modification |
| Stop button | `aria-label="Stop generating response"` |
| Streaming cursor | `aria-hidden="true"` — decorative only |
| Edit textarea | `aria-label="Edit your message"` on inline textarea |
| Retry button | `aria-label="Retry response"` |
| Slash popover | `role="listbox"` on items; announce open state with `aria-expanded` |
| Mention popover | `role="listbox"` on items; announce open state with `aria-expanded` |
| Jump to bottom | `aria-label="Scroll to latest message"` |
| Edit save/cancel | Standard button labels — "Save edit" and "Discard edit" (no icon-only ambiguity) |
| Focus after stop | Focus returns to `ChatInput` textarea after stop action |
| Focus after retry | No focus change — user may be reading the regenerated message |

---

## Animation and Motion

Inherits Phase 21 animation contract. Phase 22 additions:

| Element | Animation | Duration | Easing | CSS |
|---------|-----------|----------|--------|-----|
| Streaming cursor | `animate-cursor-blink` keyframe | 800ms | step-start | See below |
| Stop button appear | `animate-in fade-in slide-in-from-bottom-1` | 150ms | `ease-out` — shadcn default |
| Stop button disappear | `animate-out fade-out slide-out-to-bottom-1` | 100ms | `ease-in` |
| Slash popover open | `animate-in fade-in zoom-in-95` | 100ms | `ease-out` — shadcn Popover default |
| Mention popover open | Same as slash popover | 100ms | `ease-out` |
| Jump to bottom button | `animate-in fade-in slide-in-from-bottom-2` | 150ms | `ease-out` |
| Streaming indicator dot | `animate-pulse` (Tailwind) | continuous | — |
| Edit mode transition | No animation — immediate swap; avoids layout shift | — | — |

**`animate-cursor-blink` keyframe (add to `index.css`):**
```css
@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.animate-cursor-blink {
  animation: cursor-blink 800ms step-start infinite;
}

@media (prefers-reduced-motion: reduce) {
  .animate-cursor-blink {
    animation: none;
    opacity: 1;
  }
}
```

**Reduced motion:** All Phase 22 entrance/exit animations must respect `prefers-reduced-motion`. Use `motion-safe:` Tailwind prefix or `@media` guards matching the Phase 21 pattern in `index.css`.

---

## Performance Contract

| Requirement | Target | Implementation |
|-------------|--------|----------------|
| PERF-02 | First token ≤ 100ms server-to-UI | SSE connection opened before server begins generation; client-side: no React re-render batching delay — use `startTransition` for text accumulation to keep input responsive |
| PERF-03 | 1,000+ messages no jank | `@tanstack/react-virtual` with dynamic measurement; `overscan: 5`; avoid re-rendering all messages on token append — only the active streaming message re-renders |

**Token accumulation pattern:**
- Maintain a `streamingContent` string in `useStreamingChat` local state
- Append tokens via `setState(prev => prev + token)` — single string concat, not array push
- When stream ends, commit full message to React Query cache via `queryClient.setQueryData`; clear local `streamingContent`

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | All existing Phase 21 components (already installed) | not required |
| npm (non-registry) | `@tanstack/react-virtual` — standard npm package install via `pnpm add` | not applicable (not a shadcn registry block) |
| Third-party | none | not applicable |

**No third-party shadcn registries used in Phase 22.**

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
