diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 9aff8222..a7888c11 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -55,7 +55,15 @@ Plans: 4. User can click Stop to cancel an in-progress streaming response 5. User can edit a previous message to regenerate the response, or click Retry on any existing assistant message; conversations with 1,000+ messages scroll without jank via a virtualized list 6. Slash commands (`/brainstorm`, `/ask-pm`, `/ask-engineer`, `/task`, `/search`) route messages to the correct agent; `@mention` syntax routes to the named agent -**Plans**: TBD +**Plans:** 6 plans + +Plans: +- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs +- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook +- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector +- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls +- [ ] 22-04-PLAN.md — Slash commands and @mention popovers +- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration **UI hint**: yes ### Phase 23: Brainstormer Flow @@ -68,7 +76,15 @@ Plans: 3. When the user clicks "Send to PM," a handoff indicator appears in the chat showing "Brainstormer → PM" with the spec content 4. The PM agent creates one or more Nexus issues from the spec; the user can see task IDs referenced in the PM's reply 5. When an Engineer or Generalist completes a task, a status update message appears in the relevant chat conversation -**Plans**: TBD +**Plans:** 6 plans + +Plans: +- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs +- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook +- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector +- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls +- [ ] 22-04-PLAN.md — Slash commands and @mention popovers +- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration **UI hint**: yes ### Phase 24: Search, History & Branching @@ -80,7 +96,15 @@ Plans: 2. User can bookmark any message and later filter or navigate to bookmarked messages 3. Editing a message that already has a response creates a new branch; both the original and the new branch are preserved and the user can switch between them 4. User can export any conversation as a Markdown file or as a JSON file containing all messages and metadata -**Plans**: TBD +**Plans:** 6 plans + +Plans: +- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs +- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook +- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector +- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls +- [ ] 22-04-PLAN.md — Slash commands and @mention popovers +- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration **UI hint**: yes ### Phase 25: File System @@ -95,7 +119,15 @@ Plans: 5. When an agent generates a placeholder asset, `PLACEHOLDERS.md` is updated in the project directory; when the placeholder is replaced, the DB records the replacement chain and the manifest reflects the change 6. A file uploaded in a conversation linked to a project lives in `files/projects//`; a file from an unlinked conversation lives in `files/chat//`; the user can promote a chat file to project scope 7. Voice input is available when local AI is enabled: user can hold the record button, speak, see a transcription preview, and confirm to send -**Plans**: TBD +**Plans:** 6 plans + +Plans: +- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs +- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook +- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector +- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls +- [ ] 22-04-PLAN.md — Slash commands and @mention popovers +- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration **UI hint**: yes ### Phase 26: PWA & Performance @@ -109,7 +141,15 @@ Plans: 4. On a phone, the input bar is sticky at the bottom of the screen, touch targets are large enough to tap without errors, and the layout resizes correctly when the software keyboard appears 5. Pulling down on the conversation list on mobile triggers a refresh; push notifications arrive for agent mentions, task completions, and handoff requests where the platform supports them 6. The initial page load on broadband completes in under 2 seconds and on a 3G connection in under 5 seconds; PWA cached load completes in under 1 second -**Plans**: TBD +**Plans:** 6 plans + +Plans: +- [ ] 22-00-PLAN.md — Wave 0: DB migration, shared types, install virtualizer, agent-role-colors, CSS, test stubs +- [ ] 22-01-PLAN.md — SSE streaming endpoint + useStreamingChat hook +- [ ] 22-02-PLAN.md — Agent identity bar, streaming cursor, agent selector +- [ ] 22-03-PLAN.md — Edit/retry/stop message action controls +- [ ] 22-04-PLAN.md — Slash commands and @mention popovers +- [ ] 22-05-PLAN.md — Virtualized message list + full ChatPanel integration **UI hint**: yes --- diff --git a/.planning/phases/22-agent-streaming/22-00-PLAN.md b/.planning/phases/22-agent-streaming/22-00-PLAN.md new file mode 100644 index 00000000..de72cd8a --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-00-PLAN.md @@ -0,0 +1,392 @@ +--- +phase: 22-agent-streaming +plan: "00" +type: execute +wave: 0 +depends_on: [] +files_modified: + - packages/db/src/schema/chat_messages.ts + - packages/db/src/migrations/0048_add_chat_messages_updated_at.sql + - packages/shared/src/types/chat.ts + - ui/src/lib/agent-role-colors.ts + - ui/src/lib/agent-role-colors.test.ts + - ui/src/hooks/useStreamingChat.test.ts + - ui/src/components/ChatAgentSelector.test.tsx + - ui/src/components/ChatMessage.test.tsx + - ui/src/components/ChatSlashCommandPopover.test.tsx + - ui/src/components/ChatMentionPopover.test.tsx + - ui/src/components/ChatMessageIdentityBar.test.tsx + - ui/src/components/ChatMessageList.test.tsx + - ui/src/index.css + - ui/package.json +autonomous: true +requirements: + - THEME-03 +must_haves: + truths: + - "agent-role-colors.ts exports a color class for every AgentRole value from AGENT_ROLES" + - "chat_messages table has an updated_at column" + - "ChatMessage shared type includes updatedAt field" + - "@tanstack/react-virtual is installed in ui workspace" + - "Cursor blink animation is declared in index.css" + - "All Wave 0 test stubs exist and run without error" + artifacts: + - path: "ui/src/lib/agent-role-colors.ts" + provides: "AgentRole to Tailwind class map" + exports: ["agentRoleColors", "agentRoleColorDefault"] + - path: "packages/db/src/schema/chat_messages.ts" + provides: "updatedAt column on chat_messages" + contains: "updatedAt" + - path: "packages/shared/src/types/chat.ts" + provides: "updatedAt on ChatMessage type" + contains: "updatedAt" + key_links: + - from: "ui/src/lib/agent-role-colors.ts" + to: "@paperclipai/shared constants" + via: "import AgentRole" + pattern: "import.*AgentRole" +--- + + +Wave 0 foundation: DB migration adding `updated_at` to `chat_messages`, shared type update, install `@tanstack/react-virtual`, create `agent-role-colors.ts` utility (THEME-03), cursor-blink CSS animation, and all test stubs for Phase 22. + +Purpose: Provide the schema, types, dependencies, and test scaffolds that all subsequent plans need. +Output: Migration file, updated schema, shared types, agent-role-colors utility, CSS animation, test stubs, installed virtualizer package. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md +@.planning/phases/22-agent-streaming/22-UI-SPEC.md +@.planning/phases/22-agent-streaming/22-VALIDATION.md + + +From packages/shared/src/constants.ts: +```typescript +export const AGENT_ROLES = [ + "pm", "engineer", "ceo", "general", "designer", + "qa", "researcher", "devops", "cto", "cmo", "cfo", +] as const; +export type AgentRole = (typeof AGENT_ROLES)[number]; +``` + +From packages/db/src/schema/chat_messages.ts: +```typescript +export const chatMessages = pgTable("chat_messages", { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id").notNull().references(() => chatConversations.id, { onDelete: "cascade" }), + role: text("role").notNull(), + content: text("content").notNull(), + agentId: uuid("agent_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt), +})); +``` + +From packages/shared/src/types/chat.ts: +```typescript +export interface ChatMessage { + id: string; + conversationId: string; + role: "user" | "assistant" | "system"; + content: string; + agentId: string | null; + createdAt: string; +} +``` + + + + + + + Task 1: DB migration, shared types, install virtualizer, agent-role-colors, CSS animation + + - packages/db/src/schema/chat_messages.ts + - packages/shared/src/types/chat.ts + - packages/shared/src/constants.ts + - ui/src/index.css + - ui/src/lib/status-colors.ts + - ui/package.json + + + packages/db/src/schema/chat_messages.ts, + packages/db/src/migrations/0048_add_chat_messages_updated_at.sql, + packages/shared/src/types/chat.ts, + ui/src/lib/agent-role-colors.ts, + ui/src/lib/agent-role-colors.test.ts, + ui/src/index.css, + ui/package.json + + +1. Add `updatedAt` column to `chat_messages` Drizzle schema: + In `packages/db/src/schema/chat_messages.ts`, add after `createdAt`: + ``` + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + ``` + Note: NOT `.notNull()` — existing rows will have null until updated. + +2. Create migration `packages/db/src/migrations/0048_add_chat_messages_updated_at.sql`: + ```sql + ALTER TABLE "chat_messages" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now(); + ``` + +3. Update `packages/shared/src/types/chat.ts` — add `updatedAt` to `ChatMessage` interface: + ``` + updatedAt: string | null; + ``` + +4. Install `@tanstack/react-virtual`: + ```bash + pnpm add @tanstack/react-virtual --filter @paperclipai/ui + ``` + +5. Create `ui/src/lib/agent-role-colors.ts`: + ```typescript + import type { AgentRole } from "@paperclipai/shared"; + + export const agentRoleColors: Record = { + pm: "text-blue-600 dark:text-blue-400", + engineer: "text-violet-600 dark:text-violet-400", + ceo: "text-yellow-600 dark:text-yellow-400", + general: "text-yellow-600 dark:text-yellow-400", + designer: "text-pink-600 dark:text-pink-400", + qa: "text-orange-600 dark:text-orange-400", + researcher: "text-teal-600 dark:text-teal-400", + devops: "text-green-600 dark:text-green-400", + cto: "text-green-600 dark:text-green-400", + cmo: "text-neutral-600 dark:text-neutral-400", + cfo: "text-neutral-600 dark:text-neutral-400", + }; + + export const agentRoleColorDefault = "text-muted-foreground"; + ``` + +6. Create `ui/src/lib/agent-role-colors.test.ts`: + ```typescript + import { describe, it, expect } from "vitest"; + import { AGENT_ROLES } from "@paperclipai/shared"; + import { agentRoleColors, agentRoleColorDefault } from "./agent-role-colors"; + + describe("agentRoleColors", () => { + it("has an entry for every AGENT_ROLES value", () => { + for (const role of AGENT_ROLES) { + expect(agentRoleColors[role]).toBeDefined(); + expect(agentRoleColors[role]).toContain("text-"); + } + }); + + it("each entry has both light and dark variant", () => { + for (const role of AGENT_ROLES) { + expect(agentRoleColors[role]).toContain("dark:"); + } + }); + + it("exports a default fallback color", () => { + expect(agentRoleColorDefault).toBe("text-muted-foreground"); + }); + }); + ``` + +7. Add cursor-blink animation to `ui/src/index.css` (append before the closing of the file): + ```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; + } + } + ``` + + + pnpm --filter @paperclipai/ui vitest run src/lib/agent-role-colors.test.ts --reporter=verbose + + + - grep -q "updatedAt" packages/db/src/schema/chat_messages.ts + - grep -q "updated_at" packages/db/src/migrations/0048_add_chat_messages_updated_at.sql + - grep -q "updatedAt" packages/shared/src/types/chat.ts + - grep -q "agentRoleColors" ui/src/lib/agent-role-colors.ts + - grep -q "agentRoleColorDefault" ui/src/lib/agent-role-colors.ts + - grep -q "cursor-blink" ui/src/index.css + - grep -q "@tanstack/react-virtual" ui/package.json + - grep -q "prefers-reduced-motion" ui/src/index.css + + + - chat_messages schema has updatedAt column + - Migration 0048 exists with ALTER TABLE + - ChatMessage shared type has updatedAt: string | null + - @tanstack/react-virtual installed in ui workspace + - agent-role-colors.ts exports map for all 11 AgentRole values with light+dark variants + - agent-role-colors.test.ts passes (3 tests) + - cursor-blink CSS animation in index.css with reduced-motion guard + + + + + Task 2: Wave 0 test stubs for all Phase 22 components/hooks + + - ui/src/hooks/useChatMessages.ts + - ui/src/components/ChatMessage.tsx + - ui/src/components/ChatMessageList.tsx + - ui/src/components/ChatInput.tsx + + + ui/src/hooks/useStreamingChat.test.ts, + ui/src/components/ChatAgentSelector.test.tsx, + ui/src/components/ChatMessage.test.tsx, + ui/src/components/ChatSlashCommandPopover.test.tsx, + ui/src/components/ChatMentionPopover.test.tsx, + ui/src/components/ChatMessageIdentityBar.test.tsx, + ui/src/components/ChatMessageList.test.tsx + + +Create test stub files using `it.todo()` pattern (established in Phase 21 Wave 0). Minimal imports, no service mocks. + +1. `ui/src/hooks/useStreamingChat.test.ts`: + ```typescript + import { describe, it } from "vitest"; + + describe("useStreamingChat", () => { + it.todo("accumulates tokens from SSE data events into streamingContent"); + it.todo("sets isStreaming=true when stream starts, false when done"); + it.todo("clears streamingContent and invalidates query cache on done event"); + it.todo("stop() closes the EventSource and sets isStreaming=false"); + it.todo("handles SSE error event by closing connection"); + }); + ``` + +2. `ui/src/components/ChatAgentSelector.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatAgentSelector", () => { + it.todo("renders active agent icon and name when agentId is set"); + it.todo("renders 'Select agent' placeholder when no agent selected"); + it.todo("lists all workspace agents in dropdown"); + it.todo("calls onAgentChange with new agentId on selection"); + it.todo("shows 'No agents configured' when agent list is empty"); + }); + ``` + +3. `ui/src/components/ChatMessage.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatMessage", () => { + it.todo("renders user message as right-aligned bubble with plain text"); + it.todo("renders assistant message with ChatMarkdownMessage"); + it.todo("renders ChatMessageIdentityBar for assistant messages when agentName is provided"); + it.todo("shows edit pencil on hover for user messages"); + it.todo("shows retry button on hover for assistant messages"); + it.todo("hides retry button when isStreaming is true"); + it.todo("switches to inline edit textarea on pencil click"); + it.todo("renders ChatStreamingCursor when isStreaming is true"); + }); + ``` + +4. `ui/src/components/ChatSlashCommandPopover.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatSlashCommandPopover", () => { + it.todo("renders 5 slash command items when open"); + it.todo("filters commands by typed query"); + it.todo("calls onSelect with command string on item click"); + it.todo("closes on Escape key"); + it.todo("shows /search as greyed out with 'Coming soon' suffix"); + }); + ``` + +5. `ui/src/components/ChatMentionPopover.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatMentionPopover", () => { + it.todo("renders agent list filtered by query string"); + it.todo("shows agent icon, name, and role for each item"); + it.todo("calls onSelect with @agentName on item click"); + it.todo("shows 'No agents found' when filter matches nothing"); + }); + ``` + +6. `ui/src/components/ChatMessageIdentityBar.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatMessageIdentityBar", () => { + it.todo("renders agent icon at 16x16px"); + it.todo("renders agent name in semibold text"); + it.todo("renders timestamp in muted text"); + it.todo("applies role-specific color from agentRoleColors"); + }); + ``` + +7. `ui/src/components/ChatMessageList.test.tsx`: + ```typescript + import { describe, it } from "vitest"; + + describe("ChatMessageList", () => { + it.todo("renders messages using virtualizer"); + it.todo("auto-scrolls to bottom when new messages arrive"); + it.todo("shows loading skeleton when isLoading"); + it.todo("shows empty state when no messages"); + it.todo("appends streaming message as synthetic entry"); + }); + ``` + + + pnpm --filter @paperclipai/ui vitest run --reporter=verbose 2>&1 | tail -30 + + + - grep -q "it.todo" ui/src/hooks/useStreamingChat.test.ts + - grep -q "it.todo" ui/src/components/ChatAgentSelector.test.tsx + - grep -q "it.todo" ui/src/components/ChatMessage.test.tsx + - grep -q "it.todo" ui/src/components/ChatSlashCommandPopover.test.tsx + - grep -q "it.todo" ui/src/components/ChatMentionPopover.test.tsx + - grep -q "it.todo" ui/src/components/ChatMessageIdentityBar.test.tsx + - grep -q "it.todo" ui/src/components/ChatMessageList.test.tsx + + + - 7 test stub files exist with it.todo() entries + - All test stubs run without error (todos are not failures) + - Full UI test suite passes + + + + + + +- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes (all existing + new tests green, todos listed) +- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes (type check) +- `grep -q "@tanstack/react-virtual" ui/package.json` confirms install + + + +- DB migration 0048 adds updated_at to chat_messages +- ChatMessage type includes updatedAt +- @tanstack/react-virtual installed +- agent-role-colors.ts covers all 11 roles with themed classes +- Cursor-blink CSS animation declared with reduced-motion guard +- All 7 Wave 0 test stub files exist and run + + + +After completion, create `.planning/phases/22-agent-streaming/22-00-SUMMARY.md` + diff --git a/.planning/phases/22-agent-streaming/22-01-PLAN.md b/.planning/phases/22-agent-streaming/22-01-PLAN.md new file mode 100644 index 00000000..1d57a543 --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-01-PLAN.md @@ -0,0 +1,476 @@ +--- +phase: 22-agent-streaming +plan: "01" +type: execute +wave: 1 +depends_on: ["22-00"] +files_modified: + - server/src/services/chat.ts + - server/src/routes/chat.ts + - ui/src/hooks/useStreamingChat.ts + - ui/src/hooks/useStreamingChat.test.ts + - ui/src/api/chat.ts +autonomous: true +requirements: + - CHAT-01 + - CHAT-12 + - PERF-02 +must_haves: + truths: + - "Server SSE endpoint streams token events as text/event-stream" + - "Client hook accumulates tokens into streamingContent string" + - "User can stop a stream mid-generation and partial content is preserved" + - "First SSE headers are flushed before any LLM generation begins" + artifacts: + - path: "server/src/routes/chat.ts" + provides: "POST /conversations/:id/stream SSE endpoint" + contains: "text/event-stream" + - path: "server/src/services/chat.ts" + provides: "editMessage and truncateMessagesAfter methods" + contains: "editMessage" + - path: "ui/src/hooks/useStreamingChat.ts" + provides: "SSE lifecycle hook" + exports: ["useStreamingChat"] + - path: "ui/src/api/chat.ts" + provides: "postMessageAndStream method" + contains: "postMessageAndStream" + key_links: + - from: "ui/src/hooks/useStreamingChat.ts" + to: "server POST /conversations/:id/stream" + via: "fetch with ReadableStream" + pattern: "fetch.*stream" + - from: "server/src/routes/chat.ts" + to: "server/src/services/chat.ts" + via: "svc.addMessage for final commit" + pattern: "svc\\.addMessage" +--- + + +SSE streaming endpoint on the server and `useStreamingChat` hook on the client. The server uses a stub echo stream (repeats user message as fake tokens) since real LLM integration is Phase 23. The client uses `fetch` with `ReadableStream` (not native `EventSource`) because the stream endpoint is POST-based. + +Purpose: Enable real-time token-by-token message delivery (CHAT-01), stop generation (CHAT-12), and sub-100ms first-token latency (PERF-02). +Output: Server SSE route, chat service edit/truncate methods, client streaming hook, updated chat API client. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md +@.planning/phases/22-agent-streaming/22-00-SUMMARY.md + + +From server/src/routes/chat.ts (existing): +```typescript +export function chatRoutes(db: Db): Router { + const router = Router(); + const svc = chatService(db); + // ... existing routes: POST /conversations, GET /conversations/:id, etc. +} +``` + +From server/src/services/chat.ts (existing): +```typescript +export function chatService(db: Db) { + return { + createConversation(...), listConversations(...), getConversation(...), + updateConversation(...), softDeleteConversation(...), + listMessages(...), addMessage(...) + }; +} +``` + +From server/src/routes/plugins.ts (SSE pattern): +```typescript +res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +}); +res.flushHeaders(); +res.write(":ok\n\n"); +``` + +From ui/src/api/chat.ts (existing): +```typescript +export const chatApi = { + listConversations(...), createConversation(...), getConversation(...), + updateConversation(...), deleteConversation(...), listMessages(...), postMessage(...) +}; +``` + +From ui/src/hooks/useChatMessages.ts: +```typescript +export function useChatMessages(conversationId: string | null) { + // queryKey: ["chat", "messages", conversationId] + // sendMutation invalidates: ["chat", "messages", conversationId] and ["chat", "conversations"] +} +``` + + + + + + + Task 1: Server SSE streaming endpoint + edit/truncate service methods + + - server/src/routes/chat.ts + - server/src/services/chat.ts + - server/src/routes/plugins.ts (lines 1140-1185 for SSE pattern) + - packages/db/src/schema/chat_messages.ts + + + server/src/services/chat.ts, + server/src/routes/chat.ts + + +**1. Add three new methods to `chatService` in `server/src/services/chat.ts`:** + +a) `editMessage(messageId: string, content: string)` — Updates a message's content and updatedAt: + ```typescript + async editMessage(messageId: string, content: string) { + const [row] = await db + .update(chatMessages) + .set({ content, updatedAt: new Date() }) + .where(eq(chatMessages.id, messageId)) + .returning(); + return row; + }, + ``` + +b) `truncateMessagesAfter(conversationId: string, messageId: string)` — Deletes all messages in the conversation created after the given message: + ```typescript + async truncateMessagesAfter(conversationId: string, messageId: string) { + // Get the target message's createdAt + const [target] = await db + .select({ createdAt: chatMessages.createdAt }) + .from(chatMessages) + .where(eq(chatMessages.id, messageId)); + if (!target) return; + await db + .delete(chatMessages) + .where( + and( + eq(chatMessages.conversationId, conversationId), + gt(chatMessages.createdAt, target.createdAt), + ), + ); + }, + ``` + Import `gt` from `drizzle-orm` alongside existing imports. + +c) `streamEcho(content: string, signal: AbortSignal)` — Async generator that yields fake tokens (stub for Phase 23 real LLM): + ```typescript + async *streamEcho(content: string, signal: AbortSignal) { + const words = content.split(/\s+/); + for (const word of words) { + if (signal.aborted) break; + await new Promise((r) => setTimeout(r, 50)); + yield word + " "; + } + }, + ``` + +**2. Add three new routes to `chatRoutes` in `server/src/routes/chat.ts`:** + +a) `POST /conversations/:id/stream` — SSE streaming endpoint: + ```typescript + router.post("/conversations/:id/stream", async (req, res) => { + assertBoard(req); + const { content, agentId } = req.body; + if (!content || typeof content !== "string") { + res.status(400).json({ error: "content is required" }); + return; + } + + // Set SSE headers and flush BEFORE any generation (PERF-02) + 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(); + res.write(":ok\n\n"); + + const abort = new AbortController(); + req.on("close", () => abort.abort()); + + try { + let fullContent = ""; + for await (const token of svc.streamEcho(content, abort.signal)) { + if (!res.writable) break; + fullContent += token; + res.write(`data: ${JSON.stringify({ token })}\n\n`); + } + if (res.writable && !abort.signal.aborted) { + const message = await svc.addMessage(req.params.id!, { + role: "assistant", + content: fullContent.trim(), + agentId: agentId || undefined, + }); + res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`); + } + } catch (err) { + if (res.writable && !abort.signal.aborted) { + res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`); + } + } finally { + res.end(); + } + }); + ``` + CRITICAL: `res.flushHeaders()` MUST be called before the for-await loop. Check `res.writable` before every `res.write()` (same guard pattern as `plugins.ts`). + +b) `PATCH /conversations/:id/messages/:msgId` — Edit message content: + ```typescript + router.patch("/conversations/:id/messages/:msgId", async (req, res) => { + assertBoard(req); + const { content } = req.body; + if (!content || typeof content !== "string") { + res.status(400).json({ error: "content is required" }); + return; + } + const message = await svc.editMessage(req.params.msgId!, content); + if (!message) { + res.status(404).json({ error: "Message not found" }); + return; + } + res.json(message); + }); + ``` + +c) `DELETE /conversations/:id/messages/after/:msgId` — Truncate messages after a given message: + ```typescript + router.delete("/conversations/:id/messages/after/:msgId", async (req, res) => { + assertBoard(req); + await svc.truncateMessagesAfter(req.params.id!, req.params.msgId!); + res.status(204).end(); + }); + ``` + + + cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit 2>&1 | head -20 + + + - grep -q "text/event-stream" server/src/routes/chat.ts + - grep -q "flushHeaders" server/src/routes/chat.ts + - grep -q "editMessage" server/src/services/chat.ts + - grep -q "truncateMessagesAfter" server/src/services/chat.ts + - grep -q "streamEcho" server/src/services/chat.ts + - grep -q "res.writable" server/src/routes/chat.ts + - grep -q "/conversations/:id/stream" server/src/routes/chat.ts + - grep -q "/conversations/:id/messages/:msgId" server/src/routes/chat.ts + - grep -q "/conversations/:id/messages/after/:msgId" server/src/routes/chat.ts + + + - POST /conversations/:id/stream SSE endpoint exists with proper headers flushed before generation + - PATCH /conversations/:id/messages/:msgId edits message content + - DELETE /conversations/:id/messages/after/:msgId truncates subsequent messages + - chatService has editMessage, truncateMessagesAfter, and streamEcho methods + - All routes check res.writable before writing (prevents write-after-end) + - Server TypeScript compiles without errors in chat files + + + + + Task 2: useStreamingChat hook and chat API stream method + + - ui/src/hooks/useChatMessages.ts + - ui/src/api/chat.ts + - ui/src/plugins/bridge.ts + - ui/src/hooks/useStreamingChat.test.ts + + + ui/src/hooks/useStreamingChat.ts, + ui/src/api/chat.ts + + +**1. Add stream-related methods to `chatApi` in `ui/src/api/chat.ts`:** + +```typescript +async postMessageAndStream( + conversationId: string, + data: { content: string; agentId?: string }, + callbacks: { + onToken: (token: string) => void; + onDone: (messageId: string, content: string) => void; + onError: (error: string) => void; + }, + signal?: AbortSignal, +) { + const res = await fetch(`/api/conversations/${conversationId}/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + credentials: "include", + signal, + }); + if (!res.ok || !res.body) { + callbacks.onError("Failed to start stream"); + return; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const json = line.slice(6); + try { + const parsed = JSON.parse(json) as { token?: string; done?: boolean; messageId?: string; content?: string; error?: string }; + if (parsed.token) callbacks.onToken(parsed.token); + if (parsed.done && parsed.messageId) callbacks.onDone(parsed.messageId, parsed.content ?? ""); + if (parsed.error) callbacks.onError(parsed.error); + } catch { /* ignore malformed lines */ } + } + } + } catch (err) { + if (signal?.aborted) return; // Expected on stop + callbacks.onError("Stream connection lost"); + } +}, + +async savePartialMessage(conversationId: string, data: { role: "assistant"; content: string; agentId?: string }) { + return chatApi.postMessage(conversationId, data); +}, +``` + +Use `fetch` with `ReadableStream` instead of `EventSource` because the endpoint is POST-based. `EventSource` only supports GET (Open Question 2 from RESEARCH.md). + +**2. Create `ui/src/hooks/useStreamingChat.ts`:** + +```typescript +import { useRef, useState, useTransition, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { chatApi } from "../api/chat"; + +export function useStreamingChat(conversationId: string | null) { + const [streamingContent, setStreamingContent] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const abortRef = useRef(null); + const queryClient = useQueryClient(); + const [, startTransition] = useTransition(); + + const startStream = useCallback( + (userMessage: string, agentId?: string) => { + if (!conversationId) return; + + setIsStreaming(true); + setStreamingContent(""); + + const abort = new AbortController(); + abortRef.current = abort; + + chatApi.postMessageAndStream( + conversationId, + { content: userMessage, agentId }, + { + onToken: (token: string) => { + startTransition(() => { + setStreamingContent((prev) => prev + token); + }); + }, + onDone: (messageId: string, content: string) => { + setIsStreaming(false); + setStreamingContent(""); + abortRef.current = null; + // Optimistically insert the completed message into cache to avoid flash (Pitfall 2) + queryClient.setQueryData( + ["chat", "messages", conversationId], + (old: unknown) => old, // Keep existing data — invalidation will refetch + ); + queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); + queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); + }, + onError: (error: string) => { + setIsStreaming(false); + abortRef.current = null; + console.error("[useStreamingChat] Stream error:", error); + }, + }, + abort.signal, + ); + }, + [conversationId, queryClient, startTransition], + ); + + const stop = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + const partial = streamingContent; + setIsStreaming(false); + setStreamingContent(""); + // Persist partial content with [stopped] suffix (Open Question 3) + if (conversationId && partial.trim()) { + chatApi.savePartialMessage(conversationId, { + role: "assistant", + content: partial.trim() + " [stopped]", + }).then(() => { + queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); + }); + } + }, [conversationId, streamingContent, queryClient]); + + return { streamingContent, isStreaming, startStream, stop }; +} +``` + +Key design decisions: +- `startTransition` wraps `setStreamingContent` so token appends don't block user input (PERF-02) +- `AbortController` for stop (CHAT-12) — server detects `req.on("close")` +- On stop, partial content saved with " [stopped]" suffix to DB +- On done, cache invalidated (not optimistically set) to let React Query refetch the canonical data + + + cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 + + + - grep -q "useStreamingChat" ui/src/hooks/useStreamingChat.ts + - grep -q "postMessageAndStream" ui/src/api/chat.ts + - grep -q "startTransition" ui/src/hooks/useStreamingChat.ts + - grep -q "AbortController" ui/src/hooks/useStreamingChat.ts + - grep -q "\\[stopped\\]" ui/src/hooks/useStreamingChat.ts + - grep -q "savePartialMessage" ui/src/api/chat.ts + - grep -q "ReadableStream\\|getReader" ui/src/api/chat.ts + + + - useStreamingChat hook exists with startStream, stop, streamingContent, isStreaming + - chatApi.postMessageAndStream uses fetch ReadableStream for POST SSE + - chatApi.savePartialMessage persists partial content on stop + - startTransition used for token accumulation (PERF-02) + - AbortController used for stop functionality (CHAT-12) + - Partial message saved with " [stopped]" suffix on stop + - UI TypeScript compiles clean + + + + + + +- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes +- `pnpm --filter @paperclipai/server exec -- tsc --noEmit` passes (or pre-existing non-chat errors only) +- Server routes include stream, edit, truncate endpoints +- Client hook manages full SSE lifecycle + + + +- Tokens stream from server to client via SSE (CHAT-01) +- Stop generation aborts the connection and saves partial content (CHAT-12) +- SSE headers flushed before generation begins (PERF-02) +- Edit and truncate server endpoints ready for Plan 03 UI + + + +After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md` + diff --git a/.planning/phases/22-agent-streaming/22-02-PLAN.md b/.planning/phases/22-agent-streaming/22-02-PLAN.md new file mode 100644 index 00000000..3cf9ea98 --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-02-PLAN.md @@ -0,0 +1,497 @@ +--- +phase: 22-agent-streaming +plan: "02" +type: execute +wave: 1 +depends_on: ["22-00"] +files_modified: + - ui/src/components/ChatAgentSelector.tsx + - ui/src/components/ChatMessageIdentityBar.tsx + - ui/src/components/ChatStreamingCursor.tsx + - ui/src/components/ChatMessage.tsx + - ui/src/components/ChatAgentSelector.test.tsx + - ui/src/components/ChatMessageIdentityBar.test.tsx +autonomous: true +requirements: + - AGENT-04 + - CHAT-08 + - THEME-03 +must_haves: + truths: + - "Every assistant message shows the agent's name and icon above the content" + - "User can switch the active agent for a conversation via a dropdown selector" + - "Agent colors are visually distinguishable using role-specific Tailwind classes with dark: variants" + artifacts: + - path: "ui/src/components/ChatAgentSelector.tsx" + provides: "Agent dropdown in ChatPanel header" + exports: ["ChatAgentSelector"] + - path: "ui/src/components/ChatMessageIdentityBar.tsx" + provides: "Agent icon + name + timestamp above assistant messages" + exports: ["ChatMessageIdentityBar"] + - path: "ui/src/components/ChatStreamingCursor.tsx" + provides: "Blinking inline cursor during streaming" + exports: ["ChatStreamingCursor"] + - path: "ui/src/components/ChatMessage.tsx" + provides: "Extended ChatMessage with identity bar, streaming cursor, hover actions" + exports: ["ChatMessage"] + key_links: + - from: "ui/src/components/ChatMessageIdentityBar.tsx" + to: "ui/src/lib/agent-role-colors.ts" + via: "import agentRoleColors" + pattern: "agentRoleColors" + - from: "ui/src/components/ChatAgentSelector.tsx" + to: "ui/src/api/agents.ts" + via: "agentsApi.list" + pattern: "agentsApi" + - from: "ui/src/components/ChatMessage.tsx" + to: "ui/src/components/ChatMessageIdentityBar.tsx" + via: "import ChatMessageIdentityBar" + pattern: "ChatMessageIdentityBar" +--- + + +Agent identity components: agent selector dropdown (CHAT-08), message identity bar with icon/name/timestamp (AGENT-04), streaming cursor, and role-specific colors (THEME-03). Extends ChatMessage to accept and render agent identity props. + +Purpose: Make agent identity visible on every assistant message and allow users to switch agents per conversation. +Output: ChatAgentSelector, ChatMessageIdentityBar, ChatStreamingCursor components; extended ChatMessage. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md +@.planning/phases/22-agent-streaming/22-UI-SPEC.md +@.planning/phases/22-agent-streaming/22-00-SUMMARY.md + + +From ui/src/components/AgentIconPicker.tsx: +```typescript +interface AgentIconProps { + icon?: string | null; + className?: string; +} +export function AgentIcon({ icon, className }: AgentIconProps): JSX.Element; +``` + +From ui/src/api/agents.ts: +```typescript +export const agentsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/agents`), + // ... +}; +``` + +From packages/shared/src/types/chat.ts: +```typescript +export interface ChatMessage { + id: string; conversationId: string; + role: "user" | "assistant" | "system"; + content: string; agentId: string | null; + createdAt: string; updatedAt: string | null; +} +``` + +From ui/src/lib/agent-role-colors.ts (created in Plan 00): +```typescript +export const agentRoleColors: Record; +export const agentRoleColorDefault: string; +``` + +From ui/src/components/ChatMessage.tsx (current): +```typescript +interface ChatMessageProps { + role: "user" | "assistant" | "system"; + content: string; +} +export function ChatMessage({ role, content }: ChatMessageProps): JSX.Element; +``` + + + + + + + Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage + + - ui/src/components/ChatMessage.tsx + - ui/src/components/ChatMarkdownMessage.tsx + - ui/src/components/AgentIconPicker.tsx + - ui/src/lib/agent-role-colors.ts + - ui/src/lib/status-colors.ts + - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 72-95 identity bar + streaming cursor) + + + ui/src/components/ChatMessageIdentityBar.tsx, + ui/src/components/ChatStreamingCursor.tsx, + ui/src/components/ChatMessage.tsx, + ui/src/components/ChatMessageIdentityBar.test.tsx + + +**1. Create `ui/src/components/ChatMessageIdentityBar.tsx`:** + +```typescript +import { AgentIcon } from "./AgentIconPicker"; +import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors"; +import type { AgentRole } from "@paperclipai/shared"; + +interface ChatMessageIdentityBarProps { + agentName: string; + agentIcon?: string | null; + agentRole?: AgentRole | null; + timestamp?: string; + isStreaming?: boolean; +} + +export function ChatMessageIdentityBar({ + agentName, + agentIcon, + agentRole, + timestamp, + isStreaming, +}: ChatMessageIdentityBarProps) { + const colorClass = agentRole ? (agentRoleColors[agentRole] ?? agentRoleColorDefault) : agentRoleColorDefault; + + return ( +
+ + {agentName} + {isStreaming && ( + + )} + {timestamp && ( + + {new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + )} +
+ ); +} +``` + +Per UI spec: icon 16x16 (`h-4 w-4`), name 13px semibold, timestamp 11px muted, streaming dot uses `bg-cyan-400 animate-pulse` from `agentStatusDot.running`. + +**2. Create `ui/src/components/ChatStreamingCursor.tsx`:** + +```typescript +export function ChatStreamingCursor() { + return ( +
+ + pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose + + + - grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessageIdentityBar.tsx + - grep -q "agentRoleColors" ui/src/components/ChatMessageIdentityBar.tsx + - grep -q "ChatStreamingCursor" ui/src/components/ChatStreamingCursor.tsx + - grep -q "aria-hidden" ui/src/components/ChatStreamingCursor.tsx + - grep -q "animate-cursor-blink" ui/src/components/ChatStreamingCursor.tsx + - grep -q "agentName" ui/src/components/ChatMessage.tsx + - grep -q "agentRole" ui/src/components/ChatMessage.tsx + - grep -q "isStreaming" ui/src/components/ChatMessage.tsx + - grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessage.tsx + - grep -q "ChatStreamingCursor" ui/src/components/ChatMessage.tsx + - grep -q "group" ui/src/components/ChatMessage.tsx + + + - ChatMessageIdentityBar renders icon (h-4 w-4), agent name (13px semibold), timestamp (11px muted), and streaming dot + - Agent name and icon use role-specific Tailwind color classes from agentRoleColors + - ChatStreamingCursor is inline block with cursor-blink animation and aria-hidden + - ChatMessage accepts agentName, agentIcon, agentRole, timestamp, isStreaming props + - Assistant messages render identity bar when agentName is present + - Assistant messages show streaming cursor when isStreaming is true + - Tests pass for ChatMessageIdentityBar + +
+ + + Task 2: ChatAgentSelector component + + - ui/src/api/agents.ts + - ui/src/components/AgentIconPicker.tsx + - ui/src/api/chat.ts + - ui/src/hooks/useChatConversations.ts + - ui/src/components/ChatAgentSelector.test.tsx + - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 58-69 agent selector layout) + + + ui/src/components/ChatAgentSelector.tsx, + ui/src/components/ChatAgentSelector.test.tsx + + +**1. Create `ui/src/components/ChatAgentSelector.tsx`:** + +```typescript +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown } from "lucide-react"; +import { AgentIcon } from "./AgentIconPicker"; +import { agentsApi } from "../api/agents"; +import { chatApi } from "../api/chat"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors"; +import type { Agent, AgentRole } from "@paperclipai/shared"; +import { useState } from "react"; + +interface ChatAgentSelectorProps { + companyId: string; + conversationId: string | null; + agentId: string | null; + onAgentChange: (agentId: string | null) => void; +} + +export function ChatAgentSelector({ + companyId, + conversationId, + agentId, + onAgentChange, +}: ChatAgentSelectorProps) { + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + + const { data: agents, isLoading } = useQuery({ + queryKey: ["agents", companyId], + queryFn: () => agentsApi.list(companyId), + enabled: !!companyId, + }); + + const updateMutation = useMutation({ + mutationFn: (newAgentId: string) => + chatApi.updateConversation(conversationId!, { agentId: newAgentId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); + }, + }); + + const activeAgent = agents?.find((a) => a.id === agentId); + + const handleSelect = (agent: Agent) => { + onAgentChange(agent.id); + if (conversationId) { + updateMutation.mutate(agent.id); + } + setOpen(false); + }; + + if (isLoading) { + return ; + } + + return ( + + + + + + + + No agents configured + + {agents?.map((agent) => { + const colorClass = agent.role + ? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault) + : agentRoleColorDefault; + return ( + handleSelect(agent)} + className="flex items-center gap-2" + > + + {agent.name} + {agent.role} + + ); + })} + + + + + + ); +} +``` + +Per UI spec: trigger shows icon + name, max-w-120px, truncated; popover 200px wide; items show icon + name + role label; "Select agent" placeholder; "No agents configured" empty state. PATCH conversation on selection (optimistic update via onAgentChange callback). + +**2. Replace test stubs in `ui/src/components/ChatAgentSelector.test.tsx`:** +Update with real tests. Since the component uses React Query and API calls, test the rendering logic: +```typescript +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; + +describe("ChatAgentSelector", () => { + it("exports ChatAgentSelector component", async () => { + const mod = await import("./ChatAgentSelector"); + expect(mod.ChatAgentSelector).toBeDefined(); + expect(typeof mod.ChatAgentSelector).toBe("function"); + }); + + it.todo("renders active agent icon and name when agentId is set"); + it.todo("renders 'Select agent' placeholder when no agent selected"); + it.todo("lists all workspace agents in dropdown"); + it.todo("calls onAgentChange with new agentId on selection"); + it.todo("shows 'No agents configured' when agent list is empty"); +}); +``` + +Keep most tests as todo since full integration tests require QueryClientProvider mocking. The export test confirms the component loads without errors. + + + pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 + + + - grep -q "ChatAgentSelector" ui/src/components/ChatAgentSelector.tsx + - grep -q "aria-label" ui/src/components/ChatAgentSelector.tsx + - grep -q "Active agent" ui/src/components/ChatAgentSelector.tsx + - grep -q "Select agent" ui/src/components/ChatAgentSelector.tsx + - grep -q "No agents configured" ui/src/components/ChatAgentSelector.tsx + - grep -q "agentRoleColors" ui/src/components/ChatAgentSelector.tsx + - grep -q "onAgentChange" ui/src/components/ChatAgentSelector.tsx + - grep -q "updateConversation" ui/src/components/ChatAgentSelector.tsx + - grep -q "max-w-\[120px\]" ui/src/components/ChatAgentSelector.tsx + + + - ChatAgentSelector renders active agent with icon + name + ChevronDown + - Trigger max-w-120px with truncation + - "Select agent" placeholder when no agent selected + - Popover lists agents with icon, name, and role label + - "No agents configured" empty state + - Selection calls onAgentChange and PATCHes conversation + - Role-specific colors applied to agent icons + - Loading state shows Skeleton + - TypeScript compiles clean + + + +
+ + +- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes +- `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose` passes +- All new components export correctly + + + +- Assistant messages show agent name, icon, and timestamp (AGENT-04) +- Agent icon colors are role-specific with dark: variants (THEME-03) +- Agent selector dropdown allows switching active agent per conversation (CHAT-08) +- Streaming cursor blinks during active generation + + + +After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md` + diff --git a/.planning/phases/22-agent-streaming/22-03-PLAN.md b/.planning/phases/22-agent-streaming/22-03-PLAN.md new file mode 100644 index 00000000..b9d0bf2f --- /dev/null +++ b/.planning/phases/22-agent-streaming/22-03-PLAN.md @@ -0,0 +1,428 @@ +--- +phase: 22-agent-streaming +plan: "03" +type: execute +wave: 2 +depends_on: ["22-01", "22-02"] +files_modified: + - ui/src/components/ChatMessageActions.tsx + - ui/src/components/ChatMessage.tsx + - ui/src/components/ChatStopButton.tsx + - ui/src/components/ChatMessage.test.tsx +autonomous: true +requirements: + - CHAT-10 + - CHAT-11 + - CHAT-12 +must_haves: + truths: + - "User can click edit pencil on a user message to enter inline edit mode" + - "User can click retry on an assistant message to regenerate the response" + - "Stop button appears during streaming and cancels generation on click" + - "Edit/retry buttons are hidden while a stream is active" + artifacts: + - path: "ui/src/components/ChatMessageActions.tsx" + provides: "Edit and Retry hover action buttons" + exports: ["ChatMessageActions"] + - path: "ui/src/components/ChatStopButton.tsx" + provides: "Stop generating button" + exports: ["ChatStopButton"] + - path: "ui/src/components/ChatMessage.tsx" + provides: "Extended ChatMessage with edit mode, retry, actions" + contains: "ChatMessageActions" + key_links: + - from: "ui/src/components/ChatMessage.tsx" + to: "ui/src/components/ChatMessageActions.tsx" + via: "import ChatMessageActions" + pattern: "ChatMessageActions" + - from: "ui/src/components/ChatMessageActions.tsx" + to: "parent callbacks" + via: "onEdit, onRetry props" + pattern: "onEdit|onRetry" +--- + + +Message action controls: edit button on user messages (CHAT-10), retry button on assistant messages (CHAT-11), stop generation button (CHAT-12), and inline edit mode for user messages. These are the interactive controls that work with the streaming infrastructure from Plan 01. + +Purpose: Give users full control over message lifecycle — edit, retry, stop. +Output: ChatMessageActions, ChatStopButton components; ChatMessage with inline edit mode. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/22-agent-streaming/22-RESEARCH.md +@.planning/phases/22-agent-streaming/22-UI-SPEC.md +@.planning/phases/22-agent-streaming/22-01-SUMMARY.md +@.planning/phases/22-agent-streaming/22-02-SUMMARY.md + + +From ui/src/components/ChatMessage.tsx (after Plan 02): +```typescript +interface ChatMessageProps { + role: "user" | "assistant" | "system"; + content: string; + agentName?: string | null; + agentIcon?: string | null; + agentRole?: AgentRole | null; + timestamp?: string; + isStreaming?: boolean; +} +``` + +From ui/src/hooks/useStreamingChat.ts (Plan 01): +```typescript +export function useStreamingChat(conversationId: string | null): { + streamingContent: string; + isStreaming: boolean; + startStream: (userMessage: string, agentId?: string) => void; + stop: () => void; +}; +``` + +From ui/src/api/chat.ts (Plan 01 additions): +```typescript +// chatApi methods available: +chatApi.postMessage(conversationId, data) +chatApi.updateConversation(conversationId, data) +// Plan 01 additions: +chatApi.postMessageAndStream(conversationId, data, callbacks, signal) +chatApi.savePartialMessage(conversationId, data) +``` + +From server/src/routes/chat.ts (Plan 01): +``` +PATCH /conversations/:id/messages/:msgId — edit message content +DELETE /conversations/:id/messages/after/:msgId — truncate messages after +POST /conversations/:id/stream — SSE streaming +``` + + + + + + + Task 1: ChatStopButton and ChatMessageActions components + + - ui/src/components/ChatMessage.tsx + - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 96-120 stop/edit/retry) + + + ui/src/components/ChatStopButton.tsx, + ui/src/components/ChatMessageActions.tsx + + +**1. Create `ui/src/components/ChatStopButton.tsx`:** + +```typescript +import { Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ChatStopButtonProps { + onStop: () => void; +} + +export function ChatStopButton({ onStop }: ChatStopButtonProps) { + return ( +
+ +
+ ); +} +``` + +Per UI spec: centered, `variant="outline" size="sm"`, `Square` icon (filled via `fill-current`), label "Stop generating", `aria-label="Stop generating response"`. Container has `border-t border-border`. + +**2. Create `ui/src/components/ChatMessageActions.tsx`:** + +```typescript +import { Pencil, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface ChatMessageActionsProps { + role: "user" | "assistant" | "system"; + isStreaming?: boolean; + onEdit?: () => void; + onRetry?: () => void; +} + +export function ChatMessageActions({ role, isStreaming, onEdit, onRetry }: ChatMessageActionsProps) { + if (isStreaming) return null; + + if (role === "user" && onEdit) { + return ( +
+ + + + + Edit message + +
+ ); + } + + if (role === "assistant" && onRetry) { + return ( +
+ + + + + Retry response + +
+ ); + } + + return null; +} +``` + +Per UI spec: edit Pencil at top-right of user bubble (absolute positioned, group-hover visible), retry RefreshCw below assistant message (right-aligned, group-hover visible). Both hidden during streaming (`isStreaming` check). Both 14x14px icons (`h-3.5 w-3.5`), `variant="ghost" size="icon"`, with tooltip and aria-label. +
+ + pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 + + + - grep -q "Stop generating" ui/src/components/ChatStopButton.tsx + - grep -q "aria-label" ui/src/components/ChatStopButton.tsx + - grep -q "ChatMessageActions" ui/src/components/ChatMessageActions.tsx + - grep -q "Edit message" ui/src/components/ChatMessageActions.tsx + - grep -q "Retry response" ui/src/components/ChatMessageActions.tsx + - grep -q "group-hover" ui/src/components/ChatMessageActions.tsx + - grep -q "isStreaming" ui/src/components/ChatMessageActions.tsx + + + - ChatStopButton renders centered outline button with Square icon and "Stop generating" label + - ChatMessageActions renders edit Pencil for user messages (absolute, group-hover) + - ChatMessageActions renders retry RefreshCw for assistant messages (right-aligned, group-hover) + - Both action buttons hidden when isStreaming is true + - All have proper aria-labels and tooltips + - TypeScript compiles clean + +
+ + + Task 2: Extend ChatMessage with inline edit mode and wire action callbacks + + - ui/src/components/ChatMessage.tsx + - ui/src/components/ChatMessageActions.tsx + - ui/src/components/ChatStopButton.tsx + - ui/src/api/chat.ts + - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 110-142 edit/retry interaction) + + + ui/src/components/ChatMessage.tsx, + ui/src/components/ChatMessage.test.tsx + + +**1. Extend `ChatMessageProps` in `ui/src/components/ChatMessage.tsx`:** + +Add to the existing interface: +```typescript +interface ChatMessageProps { + id?: string; + role: "user" | "assistant" | "system"; + content: string; + agentName?: string | null; + agentIcon?: string | null; + agentRole?: AgentRole | null; + timestamp?: string; + isStreaming?: boolean; + isAnyStreaming?: boolean; // true when ANY message is streaming (disables edit/retry globally) + onEdit?: (messageId: string, newContent: string) => void; + onRetry?: (messageId: string) => void; +} +``` + +**2. Add inline edit mode to user message branch:** + +```typescript +import { useState } from "react"; +import { ChatMessageActions } from "./ChatMessageActions"; +import { Button } from "@/components/ui/button"; + +// Inside ChatMessage component: +const [isEditing, setIsEditing] = useState(false); +const [editValue, setEditValue] = useState(content); + +// User message branch: +if (role === "user") { + if (isEditing) { + return ( +
+
+