nexus/.planning/phases/22-agent-streaming/22-RESEARCH.md

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

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:

  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.

// 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)

// 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

  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.tsAGENT_ROLES, AgentRole
  • /opt/nexus/ui/src/components/AgentIconPicker.tsxAgentIcon component (reusable)
  • /opt/nexus/ui/src/lib/status-colors.tsagentStatusDot.running streaming indicator color
  • /opt/nexus/.planning/phases/22-agent-streaming/22-UI-SPEC.md — authoritative UI/interaction contract
  • npm view @tanstack/react-virtual version3.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)