From 6a5749e9857b4b2a44908c69ea1bedb9bb43a8f2 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 17:38:26 +0000 Subject: [PATCH] docs(22): research agent-streaming phase domain --- .../phases/22-agent-streaming/22-RESEARCH.md | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 .planning/phases/22-agent-streaming/22-RESEARCH.md diff --git a/.planning/phases/22-agent-streaming/22-RESEARCH.md b/.planning/phases/22-agent-streaming/22-RESEARCH.md new file mode 100644 index 00000000..bd9986d0 --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-RESEARCH.md @@ -0,0 +1,636 @@ +# Phase 22: Agent Streaming - Research + +**Researched:** 2026-04-01 +**Domain:** SSE streaming, React virtual list, chat message lifecycle (edit/retry/stop) +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +None — discuss phase was skipped per `workflow.skip_discuss`. + +### Claude's Discretion +All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions. + +### Deferred Ideas (OUT OF SCOPE) +None — discuss phase skipped. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CHAT-01 | Real-time streaming responses: tokens appear as they are generated, not after completion | SSE endpoint + `useStreamingChat` hook; token accumulation pattern documented below | +| CHAT-08 | Agent selector: switch which agent you are talking to mid-conversation or per-conversation | `PATCH /conversations/:id` with `agentId` already exists in chat service; `ChatAgentSelector` component; `agentsApi.list()` available | +| CHAT-10 | Message editing: edit a previous message and regenerate the response | New `PATCH /conversations/:id/messages/:msgId` endpoint + truncate-then-restream flow | +| CHAT-11 | Response regeneration: retry button on any assistant message | Reuses same truncate-then-restream flow; no extra DB endpoint needed beyond edit | +| CHAT-12 | Stop generation: cancel button available while a response is streaming | `AbortController` client-side + optional server-side cancel route; SSE closes immediately | +| INPUT-05 | Slash commands: `/brainstorm`, `/ask-pm`, `/ask-engineer`, `/task`, `/search` | `ChatSlashCommandPopover` via shadcn `` + `` (cmdk already installed) | +| INPUT-06 | `@mention` agents: type `@engineer` to route a message to a specific agent | `ChatMentionPopover`; reuse `MentionOption` pattern from `MarkdownEditor.tsx`; agent list from `agentsApi.list()` | +| AGENT-04 | Agent responses show which agent is speaking with avatar and name | `ChatMessageIdentityBar`; `AgentIcon` reused from `AgentIconPicker.tsx`; new `agent-role-colors.ts` | +| THEME-03 | Agent avatars/colors are visually distinguishable in all three themes | `text-{color}-600 dark:text-{color}-400` per-role map; no new CSS variables needed | +| PERF-02 | Streaming response latency under 100ms from server to UI | SSE headers set before generation begins; `startTransition` for token accumulation | +| PERF-03 | Conversations with 1,000+ messages scroll smoothly via a virtualized list | `@tanstack/react-virtual` `useVirtualizer` with `measureElement` for dynamic heights | + + +--- + +## Summary + +Phase 22 adds real-time streaming to the chat panel built in Phase 21. The existing server already handles SSE for plugins (see `server/src/routes/plugins.ts` and `server/src/services/plugin-stream-bus.ts`), so the SSE response pattern is established and can be replicated directly for chat. The key missing piece is a new `POST /conversations/:id/stream` route that writes token chunks as `text/event-stream` while the agent LLM generates, and a `useStreamingChat` hook on the UI side that maintains a `streamingContent` string and commits the final message to React Query cache when the stream ends. + +Three other features are bundled: the agent selector (already wired in the DB via `chatConversations.agentId`), message edit/retry (requires two new DB operations — truncate messages after a given index plus re-stream), and virtualized message list (`@tanstack/react-virtual` not yet installed). The `ChatInput` also gets slash command and @mention popovers using `cmdk` (already installed via shadcn) and the existing `MentionOption` pattern from `MarkdownEditor.tsx`. + +**Primary recommendation:** Use native `EventSource` (same as the plugin bridge in `ui/src/plugins/bridge.ts`) for the SSE client. Use `startTransition` for token append to avoid blocking user input. Install `@tanstack/react-virtual@3.13.23` for the virtualizer. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `@tanstack/react-virtual` | 3.13.23 | Virtualized message list (PERF-03) | Standard React virtualizer; `@tanstack/react-query` already in project — same vendor | +| Native `EventSource` | Browser API | SSE client for streaming tokens | Already used in `ui/src/plugins/bridge.ts`; no extra install | +| shadcn `` + `` (cmdk) | already installed | Slash command and @mention popover | Both already installed per `ui/package.json` | +| `startTransition` (React 19) | built-in | Token accumulation without blocking input | React 19 is installed (`"react": "^19.0.0"`) | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `lucide-react` | ^0.574.0 | `Square`, `Pencil`, `RefreshCw`, `ChevronDown`, `Bot` icons | Already installed; add icon imports only | +| Drizzle ORM | already installed | New migration: add `updatedAt` to `chat_messages` | Needed for edit timestamp tracking | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Native `EventSource` | `fetch` with `ReadableStream` | `fetch` stream supports POST with auth headers more cleanly but `EventSource` already has precedent in this codebase and auth is cookie-based | +| `@tanstack/react-virtual` | `react-window` | `react-virtual` supports dynamic/variable height natively via `measureElement`; `react-window` requires fixed heights | + +**Installation:** +```bash +pnpm add @tanstack/react-virtual --filter @paperclipai/ui +``` + +**Version verification:** `npm view @tanstack/react-virtual version` returned `3.13.23` on 2026-04-01. + +--- + +## Architecture Patterns + +### Recommended Project Structure + +New files in Phase 22: + +``` +ui/src/ +├── components/ +│ ├── ChatAgentSelector.tsx # Agent dropdown in header +│ ├── ChatMessageIdentityBar.tsx # Icon + name + timestamp above assistant messages +│ ├── ChatStreamingCursor.tsx # Blinking inline cursor while streaming +│ ├── ChatStopButton.tsx # "Stop generating" button above input +│ ├── ChatMessageActions.tsx # Edit (user) / Retry (assistant) hover buttons +│ ├── ChatSlashCommandPopover.tsx # / command menu +│ └── ChatMentionPopover.tsx # @mention agent autocomplete +├── hooks/ +│ └── useStreamingChat.ts # SSE lifecycle, token accumulation, stop +└── lib/ + └── agent-role-colors.ts # AgentRole → Tailwind class string map + +server/src/ +├── routes/ +│ └── chat.ts # Add: POST /conversations/:id/stream +│ └── chat.ts # Add: PATCH /conversations/:id/messages/:msgId +│ └── chat.ts # Add: DELETE /conversations/:id/messages/after/:msgId +└── services/ + └── chat.ts # Add: editMessage(), truncateMessagesAfter() + +packages/db/src/ +├── schema/ +│ └── chat_messages.ts # Add: updatedAt column +└── migrations/ + └── 0048_*.sql # updatedAt on chat_messages +``` + +### Pattern 1: SSE Streaming Endpoint (Express) + +**What:** Route that sets `text/event-stream` headers and writes token chunks as `data: {"token":"..."}` lines. +**When to use:** `POST /conversations/:id/stream` — user has sent a message and wants a streamed reply. + +```typescript +// Source: established pattern in server/src/routes/plugins.ts +router.post("/conversations/:id/stream", async (req, res) => { + assertBoard(req); + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + // Initial handshake comment + res.write(":ok\n\n"); + + // Hook into agent LLM stream + const abort = new AbortController(); + req.on("close", () => abort.abort()); + + try { + let fullContent = ""; + for await (const token of agentStream(req.params.id, req.body, abort.signal)) { + fullContent += token; + res.write(`data: ${JSON.stringify({ token })}\n\n`); + } + // Commit final message to DB + const message = await svc.addMessage(req.params.id, { + role: "assistant", + content: fullContent, + agentId: req.body.agentId, + }); + res.write(`data: ${JSON.stringify({ done: true, messageId: message.id })}\n\n`); + } catch (err) { + if (!abort.signal.aborted) { + res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`); + } + } finally { + res.end(); + } +}); +``` + +**PERF-02 note:** Call `res.flushHeaders()` before invoking the LLM. First token from the LLM arrives before any DB work — sub-100ms client-visible latency requires the SSE connection to be open *before* the generation call. + +### Pattern 2: SSE Client Hook (`useStreamingChat`) + +**What:** React hook that manages an `EventSource` (or `fetch` stream), accumulates tokens into local state, and commits on completion. +**When to use:** Called from `ChatPanel` when user sends a message. + +```typescript +// Source: bridge.ts EventSource pattern + PERF-02 startTransition guidance +import { useRef, useState, useTransition } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +export function useStreamingChat(conversationId: string | null) { + const [streamingContent, setStreamingContent] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const sourceRef = useRef(null); + const queryClient = useQueryClient(); + const [, startTransition] = useTransition(); + + const startStream = (userMessage: string, agentId?: string) => { + if (!conversationId) return; + + setIsStreaming(true); + setStreamingContent(""); + + // Post user message first, then open SSE + chatApi.postMessage(conversationId, { role: "user", content: userMessage, agentId }) + .then(() => { + const params = new URLSearchParams({ agentId: agentId ?? "" }); + const source = new EventSource( + `/api/conversations/${conversationId}/stream?${params}`, + { withCredentials: true }, + ); + sourceRef.current = source; + + source.onmessage = (e) => { + const data = JSON.parse(e.data) as { token?: string; done?: boolean; error?: string }; + if (data.token) { + // startTransition: token accumulation defers to keep input responsive + startTransition(() => { + setStreamingContent((prev) => prev + data.token); + }); + } + if (data.done) { + source.close(); + setIsStreaming(false); + setStreamingContent(""); + queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); + } + if (data.error) { + source.close(); + setIsStreaming(false); + // surface error inline + } + }; + + source.onerror = () => { + source.close(); + setIsStreaming(false); + }; + }); + }; + + const stop = () => { + sourceRef.current?.close(); + sourceRef.current = null; + setIsStreaming(false); + // Persist partial content: call PATCH endpoint to save streamingContent + "[stopped]" + }; + + return { streamingContent, isStreaming, startStream, stop }; +} +``` + +**Token accumulation note:** Single string concat (`prev + token`) is correct — not array push. When the stream ends, invalidate the React Query cache; the hook's `streamingContent` is then cleared and the completed message renders from the cache. + +**Stop flow:** `EventSource.close()` terminates the client-side connection immediately. The server detects `req.on("close")` and aborts the LLM call. The partial content already written to `streamingContent` is saved via a `PATCH` call. + +### Pattern 3: Virtualized Message List (`@tanstack/react-virtual`) + +**What:** Replace `ChatMessageList`'s plain `div` iteration with `useVirtualizer` for PERF-03. +**When to use:** `ChatMessageList.tsx` replacement. + +```typescript +// Source: @tanstack/react-virtual v3 docs — dynamic height pattern +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, useEffect } from "react"; + +export function ChatMessageList({ conversationId }: { conversationId: string }) { + const { messages, isLoading } = useChatMessages(conversationId); + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 80, // 80px default row estimate + overscan: 5, + measureElement: (el) => el.getBoundingClientRect().height, + }); + + // Auto-scroll to bottom when new messages arrive (respect user scroll-up) + useEffect(() => { + if (messages.length > 0) { + virtualizer.scrollToIndex(messages.length - 1, { align: "end" }); + } + }, [messages.length]); + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => ( +
+ +
+ ))} +
+
+ ); +} +``` + +**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)