# 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>
## 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.
</user_constraints>

---

<phase_requirements>
## 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 `<Popover>` + `<Command>` (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 |
</phase_requirements>

---

## 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 `<Popover>` + `<Command>` (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<string>("");
  const [isStreaming, setIsStreaming] = useState(false);
  const sourceRef = useRef<EventSource | null>(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<HTMLDivElement>(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 (
    <div ref={parentRef} className="flex-1 overflow-auto p-3">
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            data-index={item.index}
            ref={virtualizer.measureElement}
            style={{ position: "absolute", top: 0, transform: `translateY(${item.start}px)`, width: "100%" }}
          >
            <ChatMessage {...messages[item.index]!} />
          </div>
        ))}
      </div>
    </div>
  );
}
```

**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<string, AgentRole | null> = {
  "/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 `<Popover>` + `<Command>` (cmdk) | Already installed; keyboard navigation, filtering, and accessibility handled |
| @mention filter UI | Custom dropdown | shadcn `<Popover>` + 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<AgentRole, string> = {
  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 `<Popover>` | INPUT-05/06 | ✓ | already installed | — |
| `cmdk` (`<Command>`) | 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 <noreply@paperclip.ing>` (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)
