docs(22): research agent-streaming phase domain
This commit is contained in:
parent
3ab0955da9
commit
6a5749e985
1 changed files with 636 additions and 0 deletions
636
.planning/phases/22-agent-streaming/22-RESEARCH.md
Normal file
636
.planning/phases/22-agent-streaming/22-RESEARCH.md
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
# Phase 22: Agent Streaming - Research
|
||||
|
||||
**Researched:** 2026-04-01
|
||||
**Domain:** SSE streaming, React virtual list, chat message lifecycle (edit/retry/stop)
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## 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)
|
||||
Loading…
Add table
Reference in a new issue