+ );
+}
+```
+
+**Pitfall:** The streaming assistant message is NOT in the React Query cache during streaming. It must be rendered as an overlay or synthetic array entry alongside the virtualizer. The simplest approach: append a synthetic `{ id: "streaming", role: "assistant", content: streamingContent, isStreaming: true }` entry to the `messages` array passed into the virtualizer. When the stream ends and the cache is invalidated, the real message replaces it.
+
+### Pattern 4: Message Edit/Retry Server Flow
+
+**What:** Edit a user message (truncate subsequent messages + re-stream); Retry an assistant message (same flow, truncate from that assistant message onward).
+**When to use:** `ChatMessageActions` edit/retry triggers.
+
+New server endpoints needed:
+```
+PATCH /conversations/:id/messages/:msgId — update content of a message by ID
+DELETE /conversations/:id/messages/after/:msgId — delete all messages created after msgId (exclusive)
+```
+
+The edit/retry client flow:
+1. Client calls `PATCH` to update the user message content.
+2. Client calls `DELETE /after/:msgId` to remove the assistant message and all subsequent messages.
+3. Client calls the stream endpoint to regenerate.
+
+**DB schema gap:** `chat_messages` has no `updatedAt` column. A new migration must add it.
+
+### Pattern 5: Slash Command Routing
+
+**What:** Parse `/command` prefix in `ChatInput.onSend` to override which agent receives the message.
+**When to use:** `ChatSlashCommandPopover` inserts command token; `ChatPanel.handleSend` parses it.
+
+```typescript
+// Slash command → agent role routing table
+const SLASH_COMMAND_ROUTES: Record = {
+ "/brainstorm": "general", // Brainstormer = general role
+ "/ask-pm": "pm",
+ "/ask-engineer": "engineer",
+ "/task": "pm",
+ "/search": null, // Phase 22 stub — no-op
+};
+
+function resolveAgentFromContent(
+ content: string,
+ agents: Agent[],
+ activeAgentId: string | null,
+): string | null {
+ // Slash command takes highest priority
+ const slashMatch = content.match(/^(\/\S+)/);
+ if (slashMatch) {
+ const cmd = slashMatch[1] as string;
+ const role = SLASH_COMMAND_ROUTES[cmd];
+ if (role) {
+ return agents.find((a) => a.role === role)?.id ?? activeAgentId;
+ }
+ }
+ // @mention takes second priority
+ const mentionMatch = content.match(/@(\S+)/);
+ if (mentionMatch) {
+ const name = mentionMatch[1]!.toLowerCase();
+ return agents.find((a) => a.name.toLowerCase().startsWith(name))?.id ?? activeAgentId;
+ }
+ return activeAgentId;
+}
+```
+
+### Anti-Patterns to Avoid
+
+- **Re-rendering the full message list on each token:** Only the streaming message entry should update. Keep `streamingContent` in `useStreamingChat` local state, not in the React Query cache, during streaming.
+- **Opening SSE before posting the user message:** Post the user message to DB first, *then* open the SSE connection. Otherwise a server restart during the gap could lose the user message.
+- **Calling `res.write()` after `res.end()`:** The `req.on("close")` handler must check `res.writable` before writing (same guard used in `plugin-stream-bus.ts`).
+- **Using `getNextPageParam` cursor for the streaming message:** The streaming overlay message must be synthetic — do not try to page-load it.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Virtualized scrolling | Custom windowing logic | `@tanstack/react-virtual` `useVirtualizer` | Dynamic height measurement, overscan, scroll restoration are all non-trivial to reimplement |
+| Slash command UI | Custom popover list | shadcn `` + `` (cmdk) | Already installed; keyboard navigation, filtering, and accessibility handled |
+| @mention filter UI | Custom dropdown | shadcn `` + inline filter | Same cmdk approach — avoids focus trap conflicts with textarea |
+| SSE client lifecycle | Custom retry/reconnect wrapper | Native `EventSource` + `useRef` guard | Browser handles reconnect; the plugin bridge pattern (bridge.ts) already works |
+| Token streaming accumulation | Character-diff patching | Single string concat `prev + token` | Tokens arrive in order; no diff needed |
+
+**Key insight:** This codebase already has working SSE client code in `bridge.ts` and working SSE server code in `plugins.ts`. The chat streaming implementation is a targeted adaptation of those two, not a novel build.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Auth on SSE Endpoint
+
+**What goes wrong:** `EventSource` does not support custom `Authorization` headers — only `withCredentials` for cookies.
+**Why it happens:** The `EventSource` spec predates bearer tokens.
+**How to avoid:** The project uses cookie-based auth (`withCredentials: true`), which `EventSource` supports. This is how the plugin bridge already handles it. Do NOT attempt to pass a token via query string.
+**Warning signs:** 401 on the SSE endpoint despite a valid cookie session.
+
+### Pitfall 2: Streaming message not in React Query cache causes flash
+
+**What goes wrong:** Stream ends, React Query invalidates, the in-flight synthetic message disappears for one frame before the fetched message appears.
+**Why it happens:** `invalidateQueries` is async; if the cache is empty during the refetch, the list shows the empty state briefly.
+**How to avoid:** Use `queryClient.setQueryData` to optimistically insert the completed message into the cache *before* calling `invalidateQueries`. The `done` SSE event carries the `messageId` and full content.
+
+### Pitfall 3: Virtualizer height mismatch with streaming text
+
+**What goes wrong:** As tokens stream in, message height grows; the virtualizer's estimate becomes wrong, causing items to overlap.
+**Why it happens:** `estimateSize` returns a static value; `measureElement` is only called after mounting.
+**How to avoid:** Call `virtualizer.measure()` after each token append to force re-measurement. Alternatively, use a fixed-height streaming placeholder and only virtualize committed (non-streaming) messages.
+
+### Pitfall 4: Double-send on retry/edit
+
+**What goes wrong:** User clicks Retry while a stream is already in progress (e.g., from a previous send).
+**Why it happens:** No guard against concurrent streams.
+**How to avoid:** Disable all Retry/Edit buttons while `isStreaming` is true (the UI spec already specifies this).
+
+### Pitfall 5: `DELETE /after/:msgId` race with React Query cache
+
+**What goes wrong:** React Query cache still holds the deleted messages while the refetch is in flight, causing them to flash back momentarily.
+**Why it happens:** `invalidateQueries` triggers a refetch that takes time.
+**How to avoid:** Optimistically remove the messages from the cache via `queryClient.setQueryData` before the delete request, matching the pattern used by `sendMutation.onSuccess`.
+
+### Pitfall 6: `chat_messages.updatedAt` migration required for edit feature
+
+**What goes wrong:** `PATCH /conversations/:id/messages/:msgId` has no `updatedAt` to update — the current schema omits this column.
+**Why it happens:** Phase 21 schema was minimal (createdAt only).
+**How to avoid:** Wave 0 must include a Drizzle migration adding `updated_at timestamptz DEFAULT now()` to `chat_messages`.
+
+---
+
+## Code Examples
+
+### SSE Server Headers (verified pattern from plugins.ts)
+
+```typescript
+// Source: /opt/nexus/server/src/routes/plugins.ts (existing SSE pattern)
+res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no", // Required for nginx proxy buffering
+});
+res.flushHeaders();
+res.write(":ok\n\n"); // Initial connection handshake comment
+```
+
+### SSE Client (verified pattern from bridge.ts)
+
+```typescript
+// Source: /opt/nexus/ui/src/plugins/bridge.ts (existing EventSource usage)
+const source = new EventSource(url, { withCredentials: true });
+sourceRef.current = source;
+// Close on unmount:
+source.close();
+sourceRef.current = null;
+```
+
+### Agent Role Colors (new utility)
+
+```typescript
+// Source: THEME-03 from 22-UI-SPEC.md; colors match status-colors.ts pattern
+import type { AgentRole } from "@paperclipai/shared";
+
+export const agentRoleColors: Record = {
+ pm: "text-blue-600 dark:text-blue-400",
+ engineer: "text-violet-600 dark:text-violet-400",
+ ceo: "text-yellow-600 dark:text-yellow-400",
+ general: "text-yellow-600 dark:text-yellow-400",
+ designer: "text-pink-600 dark:text-pink-400",
+ qa: "text-orange-600 dark:text-orange-400",
+ researcher: "text-teal-600 dark:text-teal-400",
+ devops: "text-green-600 dark:text-green-400",
+ cto: "text-green-600 dark:text-green-400",
+ cmo: "text-neutral-600 dark:text-neutral-400",
+ cfo: "text-neutral-600 dark:text-neutral-400",
+};
+
+export const agentRoleColorDefault = "text-muted-foreground";
+```
+
+### Streaming Cursor CSS (from 22-UI-SPEC.md)
+
+```css
+/* Add to ui/src/index.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;
+ }
+}
+```
+
+### DB Migration: `chat_messages.updatedAt`
+
+```sql
+-- Add to new migration 0048_*.sql
+ALTER TABLE "chat_messages" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Polling for new messages | SSE for streaming tokens | Established in this project via plugin bridge | SSE gives per-token delivery; polling can't compete for streaming |
+| Full list re-render on new message | `@tanstack/react-virtual` windowed list | v3.x is current (2024) | Required for PERF-03 (1,000+ messages) |
+| `react-window` with fixed heights | `@tanstack/react-virtual` with `measureElement` | react-virtual v3 | Variable height markdown messages need dynamic measurement |
+
+**Deprecated/outdated:**
+- `react-window`: Requires fixed item heights; incompatible with variable-height markdown messages. Do not use.
+
+---
+
+## Open Questions
+
+1. **LLM integration — no agent adapter is wired to the chat stream yet**
+ - What we know: `chatService` stores messages but has no LLM call; agents have `adapterType` / `adapterConfig` that could be used.
+ - What's unclear: Phase 22's success criteria assume streaming responses "from any agent" — but Phase 23 is listed as the phase that wires agent behavior (AGENT-01/02/03). Phase 22 may be expected to deliver streaming UI plumbing with a *mock/echo* LLM response, not a real agent call.
+ - Recommendation: Implement a stub echo stream in the server (repeats back the user's message with fake tokens) to satisfy CHAT-01/PERF-02 without requiring LLM integration. Document the stub clearly so Phase 23 can replace it.
+
+2. **`EventSource` POST limitation**
+ - What we know: Native `EventSource` only supports GET. The stream endpoint will need to be GET-based (with the message content passed as a query parameter) or use `fetch` with `ReadableStream` for POST support.
+ - What's unclear: Large messages may exceed GET query string limits (~2,000 chars in some proxies).
+ - Recommendation: Use `fetch` with streaming response body (`res.body.getReader()`) for the stream endpoint, accepting POST. This is less idiomatic than `EventSource` but avoids the GET/length constraint. The plugin bridge uses `EventSource` because it's a GET subscription; this chat stream needs to send a body.
+
+3. **Abort/stop persistence of partial message**
+ - What we know: `stop()` closes the SSE connection. The partial `streamingContent` string lives in client state only.
+ - What's unclear: Should the partial message be saved to DB on stop? The UI spec says append `[stopped]` suffix and preserve partial content.
+ - Recommendation: On stop, call `POST /conversations/:id/messages` with `{ role: "assistant", content: streamingContent + " [stopped]" }`. This persists the partial response so it survives page refresh.
+
+---
+
+## Environment Availability
+
+Step 2.6: All dependencies are either browser APIs (EventSource/fetch), already installed npm packages (shadcn, cmdk, @tanstack/react-query), or standard npm packages (pnpm add @tanstack/react-virtual). No external services, databases, or CLIs beyond the existing dev stack are needed.
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| `@tanstack/react-virtual` | PERF-03 | ✗ | — | None — must install |
+| `EventSource` / `fetch` | CHAT-01 streaming | ✓ | Browser API | — |
+| shadcn `` | INPUT-05/06 | ✓ | already installed | — |
+| `cmdk` (``) | INPUT-05 | ✓ | already installed | — |
+| Drizzle ORM + migrations | chat_messages.updatedAt | ✓ | already installed | — |
+
+**Missing dependencies with no fallback:**
+- `@tanstack/react-virtual` — install via `pnpm add @tanstack/react-virtual --filter @paperclipai/ui` in Wave 0.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | vitest ^3.0.5 |
+| Config file | `ui/vitest.config.ts` |
+| Quick run command | `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` |
+| Full suite command | `pnpm vitest run` (root, all workspaces) |
+
+**Environment note:** `ui/vitest.config.ts` sets `environment: "node"`. Tests that need DOM (like `ChatInput.test.tsx`) use the `// @vitest-environment jsdom` file-level annotation. New chat component tests should follow this pattern.
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CHAT-01 | Tokens accumulate in `streamingContent` on SSE message events | unit | `pnpm --filter @paperclipai/ui vitest run --reporter=verbose src/hooks/useStreamingChat.test.ts` | ❌ Wave 0 |
+| CHAT-08 | Agent selector dispatches `PATCH /conversations/:id` with `agentId` | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatAgentSelector.test.tsx` | ❌ Wave 0 |
+| CHAT-10 | Edit textarea shows pre-filled content; "Save edit" submits PATCH | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessage.test.tsx` | ❌ Wave 0 |
+| CHAT-11 | Retry button hidden during streaming; visible otherwise | unit | included in ChatMessage.test.tsx | ❌ Wave 0 |
+| CHAT-12 | Stop button calls `stop()`; removes SSE source | unit | included in useStreamingChat.test.ts | ❌ Wave 0 |
+| INPUT-05 | Typing `/` opens slash popover; selecting item inserts command token | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx` | ❌ Wave 0 |
+| INPUT-06 | Typing `@` opens mention popover filtered by query | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatMentionPopover.test.tsx` | ❌ Wave 0 |
+| AGENT-04 | `ChatMessageIdentityBar` renders agent name and icon | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx` | ❌ Wave 0 |
+| THEME-03 | `agentRoleColors` has entry for every AGENT_ROLES value | unit | `pnpm --filter @paperclipai/ui vitest run src/lib/agent-role-colors.test.ts` | ❌ Wave 0 |
+| PERF-02 | `res.flushHeaders()` called before LLM generation in stream route | manual | — | — |
+| PERF-03 | Virtualizer renders only visible items from 1,000-item list | unit | `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageList.test.tsx` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter @paperclipai/ui vitest run --reporter=verbose`
+- **Per wave merge:** `pnpm vitest run` (full root suite)
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `ui/src/hooks/useStreamingChat.test.ts` — covers CHAT-01, CHAT-12
+- [ ] `ui/src/components/ChatAgentSelector.test.tsx` — covers CHAT-08
+- [ ] `ui/src/components/ChatMessage.test.tsx` — covers CHAT-10, CHAT-11
+- [ ] `ui/src/components/ChatSlashCommandPopover.test.tsx` — covers INPUT-05
+- [ ] `ui/src/components/ChatMentionPopover.test.tsx` — covers INPUT-06
+- [ ] `ui/src/components/ChatMessageIdentityBar.test.tsx` — covers AGENT-04
+- [ ] `ui/src/lib/agent-role-colors.test.ts` — covers THEME-03
+- [ ] `ui/src/components/ChatMessageList.test.tsx` — covers PERF-03
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+No `CLAUDE.md` was found at the project root. Project conventions are inferred from the existing codebase:
+
+- Drizzle ORM: use object-syntax `(table) => ({ ... })` for index callbacks (matches `chat_conversations.ts`, `chat_messages.ts`, `agents.ts`, `documents.ts`).
+- Vitest: use `it.todo()` (not `it.skip()`) for Wave 0 scaffolded tests (established in Phase 21).
+- Minimal test stubs in Wave 0 — no service mocks until implementations are wired (established in Phase 21).
+- shadcn preset: new-york / neutral / css-variables (from `22-UI-SPEC.md`, unchanged from Phase 21).
+- No new CSS variables — use existing tokens via Tailwind `dark:` variants for theme support.
+- React 19 is installed — use `startTransition` from `react` (not the deprecated `unstable_` variant).
+- Commit message footer: `Co-Authored-By: Paperclip ` (from SKILL.md Paperclip skill).
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- `/opt/nexus/server/src/routes/plugins.ts` — SSE server pattern (headers, flush, write, close guard)
+- `/opt/nexus/ui/src/plugins/bridge.ts` — SSE client pattern (`EventSource`, `useRef`, `withCredentials`)
+- `/opt/nexus/server/src/services/chat.ts` — existing chat service (what exists, what's missing)
+- `/opt/nexus/server/src/routes/chat.ts` — existing chat routes
+- `/opt/nexus/packages/db/src/schema/chat_messages.ts` / `chat_conversations.ts` — DB schema state
+- `/opt/nexus/packages/shared/src/types/chat.ts` — shared types
+- `/opt/nexus/packages/shared/src/constants.ts` — `AGENT_ROLES`, `AgentRole`
+- `/opt/nexus/ui/src/components/AgentIconPicker.tsx` — `AgentIcon` component (reusable)
+- `/opt/nexus/ui/src/lib/status-colors.ts` — `agentStatusDot.running` streaming indicator color
+- `/opt/nexus/.planning/phases/22-agent-streaming/22-UI-SPEC.md` — authoritative UI/interaction contract
+- `npm view @tanstack/react-virtual version` → `3.13.23` (verified 2026-04-01)
+
+### Secondary (MEDIUM confidence)
+- `@tanstack/react-virtual` v3 docs (dynamic measurement pattern with `measureElement`) — cross-checked against package version returned by npm registry
+
+### Tertiary (LOW confidence)
+- None.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — packages verified from `package.json` and npm registry
+- Architecture: HIGH — SSE pattern copied from verified existing code; stream bus and bridge.ts patterns match exactly
+- Pitfalls: HIGH — derived from direct code inspection of existing patterns and known EventSource constraints
+- Open questions: MEDIUM — LLM integration scope is inferred from requirement traceability table, not explicit documentation
+
+**Research date:** 2026-04-01
+**Valid until:** 2026-05-01 (stable libraries; SSE is a browser standard)