32 KiB
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:
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.
// 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.
// 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.
// 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:
- Client calls
PATCHto update the user message content. - Client calls
DELETE /after/:msgIdto remove the assistant message and all subsequent messages. - 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.
// 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
streamingContentinuseStreamingChatlocal 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()afterres.end(): Thereq.on("close")handler must checkres.writablebefore writing (same guard used inplugin-stream-bus.ts). - Using
getNextPageParamcursor 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)
// 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)
// 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)
// 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)
/* 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
-- 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
-
LLM integration — no agent adapter is wired to the chat stream yet
- What we know:
chatServicestores messages but has no LLM call; agents haveadapterType/adapterConfigthat 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.
- What we know:
-
EventSourcePOST limitation- What we know: Native
EventSourceonly supports GET. The stream endpoint will need to be GET-based (with the message content passed as a query parameter) or usefetchwithReadableStreamfor POST support. - What's unclear: Large messages may exceed GET query string limits (~2,000 chars in some proxies).
- Recommendation: Use
fetchwith streaming response body (res.body.getReader()) for the stream endpoint, accepting POST. This is less idiomatic thanEventSourcebut avoids the GET/length constraint. The plugin bridge usesEventSourcebecause it's a GET subscription; this chat stream needs to send a body.
- What we know: Native
-
Abort/stop persistence of partial message
- What we know:
stop()closes the SSE connection. The partialstreamingContentstring 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/messageswith{ role: "assistant", content: streamingContent + " [stopped]" }. This persists the partial response so it survives page refresh.
- What we know:
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 viapnpm add @tanstack/react-virtual --filter @paperclipai/uiin 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-12ui/src/components/ChatAgentSelector.test.tsx— covers CHAT-08ui/src/components/ChatMessage.test.tsx— covers CHAT-10, CHAT-11ui/src/components/ChatSlashCommandPopover.test.tsx— covers INPUT-05ui/src/components/ChatMentionPopover.test.tsx— covers INPUT-06ui/src/components/ChatMessageIdentityBar.test.tsx— covers AGENT-04ui/src/lib/agent-role-colors.test.ts— covers THEME-03ui/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 (matcheschat_conversations.ts,chat_messages.ts,agents.ts,documents.ts). - Vitest: use
it.todo()(notit.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
startTransitionfromreact(not the deprecatedunstable_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—AgentIconcomponent (reusable)/opt/nexus/ui/src/lib/status-colors.ts—agentStatusDot.runningstreaming indicator color/opt/nexus/.planning/phases/22-agent-streaming/22-UI-SPEC.md— authoritative UI/interaction contractnpm view @tanstack/react-virtual version→3.13.23(verified 2026-04-01)
Secondary (MEDIUM confidence)
@tanstack/react-virtualv3 docs (dynamic measurement pattern withmeasureElement) — cross-checked against package version returned by npm registry
Tertiary (LOW confidence)
- None.
Metadata
Confidence breakdown:
- Standard stack: HIGH — packages verified from
package.jsonand 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)