docs(22-agent-streaming): create phase plan — 6 plans in 4 waves
This commit is contained in:
parent
01bde5398c
commit
9d1a91b4bc
7 changed files with 3117 additions and 5 deletions
|
|
@ -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/<slug>/`; a file from an unlinked conversation lives in `files/chat/<conversation-id>/`; 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
|
||||
|
||||
---
|
||||
|
|
|
|||
392
.planning/phases/22-agent-streaming/22-00-PLAN.md
Normal file
392
.planning/phases/22-agent-streaming/22-00-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
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;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DB migration, shared types, install virtualizer, agent-role-colors, CSS animation</name>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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<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";
|
||||
```
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui vitest run src/lib/agent-role-colors.test.ts --reporter=verbose</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wave 0 test stubs for all Phase 22 components/hooks</name>
|
||||
<read_first>
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/components/ChatMessage.tsx
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
</read_first>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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");
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui vitest run --reporter=verbose 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 7 test stub files exist with it.todo() entries
|
||||
- All test stubs run without error (todos are not failures)
|
||||
- Full UI test suite passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-00-SUMMARY.md`
|
||||
</output>
|
||||
476
.planning/phases/22-agent-streaming/22-01-PLAN.md
Normal file
476
.planning/phases/22-agent-streaming/22-01-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
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"]
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Server SSE streaming endpoint + edit/truncate service methods</name>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<files>
|
||||
server/src/services/chat.ts,
|
||||
server/src/routes/chat.ts
|
||||
</files>
|
||||
<action>
|
||||
**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();
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: useStreamingChat hook and chat API stream method</name>
|
||||
<read_first>
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/api/chat.ts
|
||||
- ui/src/plugins/bridge.ts
|
||||
- ui/src/hooks/useStreamingChat.test.ts
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/hooks/useStreamingChat.ts,
|
||||
ui/src/api/chat.ts
|
||||
</files>
|
||||
<action>
|
||||
**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<string>("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md`
|
||||
</output>
|
||||
497
.planning/phases/22-agent-streaming/22-02-PLAN.md
Normal file
497
.planning/phases/22-agent-streaming/22-02-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
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<Agent[]>(`/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<AgentRole, string>;
|
||||
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;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatMessageIdentityBar.tsx,
|
||||
ui/src/components/ChatStreamingCursor.tsx,
|
||||
ui/src/components/ChatMessage.tsx,
|
||||
ui/src/components/ChatMessageIdentityBar.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**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 (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AgentIcon icon={agentIcon} className={`h-4 w-4 ${colorClass}`} />
|
||||
<span className={`text-[13px] font-semibold ${colorClass}`}>{agentName}</span>
|
||||
{isStreaming && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
)}
|
||||
{timestamp && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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 (
|
||||
<span
|
||||
className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per UI spec: `w-2 h-[1em] bg-foreground/70 animate-cursor-blink`, `aria-hidden="true"` (decorative only).
|
||||
|
||||
**3. Extend `ChatMessage` props and rendering in `ui/src/components/ChatMessage.tsx`:**
|
||||
|
||||
Update the `ChatMessageProps` interface to:
|
||||
```typescript
|
||||
interface ChatMessageProps {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
agentName?: string | null;
|
||||
agentIcon?: string | null;
|
||||
agentRole?: AgentRole | null;
|
||||
timestamp?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Import `AgentRole` from `@paperclipai/shared`, `ChatMessageIdentityBar`, and `ChatStreamingCursor`.
|
||||
|
||||
Update the assistant/system rendering branch to:
|
||||
```tsx
|
||||
return (
|
||||
<div className="max-w-full group relative">
|
||||
{agentName && (
|
||||
<ChatMessageIdentityBar
|
||||
agentName={agentName}
|
||||
agentIcon={agentIcon}
|
||||
agentRole={agentRole}
|
||||
timestamp={timestamp}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
<ChatMarkdownMessage content={content} />
|
||||
{isStreaming && <ChatStreamingCursor />}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
The `group` class enables hover-reveal for edit/retry buttons (Plan 03). User message branch remains unchanged for now (edit action is Plan 03).
|
||||
|
||||
**4. Replace test stubs in `ui/src/components/ChatMessageIdentityBar.test.tsx`** with real tests:
|
||||
```typescript
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
||||
|
||||
describe("ChatMessageIdentityBar", () => {
|
||||
it("renders agent name in semibold text", () => {
|
||||
render(<ChatMessageIdentityBar agentName="PM Agent" />);
|
||||
expect(screen.getByText("PM Agent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders timestamp when provided", () => {
|
||||
render(<ChatMessageIdentityBar agentName="Test" timestamp="2026-01-01T12:30:00Z" />);
|
||||
// Should contain formatted time
|
||||
const el = screen.getByText(/12:30/);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies role-specific color class", () => {
|
||||
const { container } = render(
|
||||
<ChatMessageIdentityBar agentName="PM" agentRole="pm" />
|
||||
);
|
||||
const nameEl = container.querySelector(".font-semibold");
|
||||
expect(nameEl?.className).toContain("text-blue-600");
|
||||
expect(nameEl?.className).toContain("dark:text-blue-400");
|
||||
});
|
||||
|
||||
it("shows streaming indicator dot when isStreaming", () => {
|
||||
const { container } = render(
|
||||
<ChatMessageIdentityBar agentName="Test" isStreaming={true} />
|
||||
);
|
||||
const dot = container.querySelector(".animate-pulse");
|
||||
expect(dot).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
If `@testing-library/react` is not installed, use `createRoot` + `container.querySelector` pattern from `ChatInput.test.tsx`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ChatAgentSelector component</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatAgentSelector.tsx,
|
||||
ui/src/components/ChatAgentSelector.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**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 <Skeleton className="h-7 w-20 rounded" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 max-w-[120px] gap-1 px-2 text-xs"
|
||||
aria-label="Active agent"
|
||||
>
|
||||
{activeAgent ? (
|
||||
<>
|
||||
<AgentIcon
|
||||
icon={activeAgent.icon}
|
||||
className={`h-3.5 w-3.5 ${activeAgent.role ? (agentRoleColors[activeAgent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault}`}
|
||||
/>
|
||||
<span className="truncate">{activeAgent.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select agent</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>No agents configured</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{agents?.map((agent) => {
|
||||
const colorClass = agent.role
|
||||
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
|
||||
: agentRoleColorDefault;
|
||||
return (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`
|
||||
</output>
|
||||
428
.planning/phases/22-agent-streaming/22-03-PLAN.md
Normal file
428
.planning/phases/22-agent-streaming/22-03-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: ChatStopButton and ChatMessageActions components</name>
|
||||
<read_first>
|
||||
- ui/src/components/ChatMessage.tsx
|
||||
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 96-120 stop/edit/retry)
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatStopButton.tsx,
|
||||
ui/src/components/ChatMessageActions.tsx
|
||||
</files>
|
||||
<action>
|
||||
**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 (
|
||||
<div className="flex justify-center py-2 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating response"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop generating
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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 (
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit message"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit message</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (role === "assistant" && onRetry) {
|
||||
return (
|
||||
<div className="flex justify-end mt-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hidden group-hover:inline-flex"
|
||||
onClick={onRetry}
|
||||
aria-label="Retry response"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Retry response</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extend ChatMessage with inline edit mode and wire action callbacks</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatMessage.tsx,
|
||||
ui/src/components/ChatMessage.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**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 (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] w-full">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[40px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label="Edit your message"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setIsEditing(false); setEditValue(content); }}
|
||||
>
|
||||
Discard edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!editValue.trim()}
|
||||
onClick={() => {
|
||||
if (id && onEdit && editValue.trim()) {
|
||||
onEdit(id, editValue.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="relative group max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm">
|
||||
{content}
|
||||
<ChatMessageActions
|
||||
role="user"
|
||||
isStreaming={isAnyStreaming}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per UI spec: inline textarea pre-filled with content, "Save edit" (variant="default" size="sm", disabled when empty), "Discard edit" (variant="ghost" size="sm"), `aria-label="Edit your message"`.
|
||||
|
||||
**3. Update assistant message branch to include retry action:**
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<div className="max-w-full group relative">
|
||||
{agentName && (
|
||||
<ChatMessageIdentityBar
|
||||
agentName={agentName}
|
||||
agentIcon={agentIcon}
|
||||
agentRole={agentRole}
|
||||
timestamp={timestamp}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
<ChatMarkdownMessage content={content} />
|
||||
{isStreaming && <ChatStreamingCursor />}
|
||||
<ChatMessageActions
|
||||
role="assistant"
|
||||
isStreaming={isAnyStreaming}
|
||||
onRetry={id && onRetry ? () => onRetry(id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
**4. Replace test stubs in `ui/src/components/ChatMessage.test.tsx`:**
|
||||
```typescript
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("ChatMessage", () => {
|
||||
it("exports ChatMessage component", async () => {
|
||||
const mod = await import("./ChatMessage");
|
||||
expect(mod.ChatMessage).toBeDefined();
|
||||
});
|
||||
|
||||
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 isAnyStreaming is true");
|
||||
it.todo("switches to inline edit textarea on pencil click");
|
||||
it.todo("renders ChatStreamingCursor when isStreaming is true");
|
||||
it.todo("Save edit button disabled when edit textarea is empty");
|
||||
it.todo("Discard edit reverts to read-only bubble");
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "isEditing" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "Save edit" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "Discard edit" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "Edit your message" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "onEdit" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "onRetry" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "isAnyStreaming" ui/src/components/ChatMessage.tsx
|
||||
- grep -q "ChatMessageActions" ui/src/components/ChatMessage.tsx
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- ChatMessage user messages show edit pencil on hover (group-hover)
|
||||
- Edit pencil click opens inline textarea with "Save edit" / "Discard edit" buttons
|
||||
- Save edit is disabled when textarea empty; calls onEdit(id, newContent) on click
|
||||
- Discard edit reverts to read-only bubble
|
||||
- Assistant messages show retry RefreshCw on hover; calls onRetry(id) on click
|
||||
- All edit/retry actions hidden when isAnyStreaming is true
|
||||
- TypeScript compiles clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
||||
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- User messages have edit capability with inline textarea (CHAT-10)
|
||||
- Assistant messages have retry button (CHAT-11)
|
||||
- Stop button component ready for ChatPanel integration (CHAT-12)
|
||||
- All actions disabled during active streaming
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`
|
||||
</output>
|
||||
446
.planning/phases/22-agent-streaming/22-04-PLAN.md
Normal file
446
.planning/phases/22-agent-streaming/22-04-PLAN.md
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
---
|
||||
phase: 22-agent-streaming
|
||||
plan: "04"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["22-00"]
|
||||
files_modified:
|
||||
- ui/src/components/ChatSlashCommandPopover.tsx
|
||||
- ui/src/components/ChatMentionPopover.tsx
|
||||
- ui/src/lib/slash-commands.ts
|
||||
- ui/src/components/ChatSlashCommandPopover.test.tsx
|
||||
- ui/src/components/ChatMentionPopover.test.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- INPUT-05
|
||||
- INPUT-06
|
||||
must_haves:
|
||||
truths:
|
||||
- "Typing / as first character in ChatInput opens the slash command popover"
|
||||
- "Typing @ in ChatInput opens the agent mention popover"
|
||||
- "Selecting a slash command inserts the command prefix into the textarea"
|
||||
- "Selecting an @mention inserts @agentName into the textarea"
|
||||
- "/search command is shown but greyed out with 'Coming soon' suffix"
|
||||
artifacts:
|
||||
- path: "ui/src/components/ChatSlashCommandPopover.tsx"
|
||||
provides: "Slash command menu UI"
|
||||
exports: ["ChatSlashCommandPopover"]
|
||||
- path: "ui/src/components/ChatMentionPopover.tsx"
|
||||
provides: "Agent @mention autocomplete UI"
|
||||
exports: ["ChatMentionPopover"]
|
||||
- path: "ui/src/lib/slash-commands.ts"
|
||||
provides: "Slash command definitions and routing table"
|
||||
exports: ["SLASH_COMMANDS", "resolveAgentFromContent"]
|
||||
key_links:
|
||||
- from: "ui/src/lib/slash-commands.ts"
|
||||
to: "@paperclipai/shared constants"
|
||||
via: "AgentRole type for routing"
|
||||
pattern: "AgentRole"
|
||||
- from: "ui/src/components/ChatSlashCommandPopover.tsx"
|
||||
to: "ui/src/lib/slash-commands.ts"
|
||||
via: "import SLASH_COMMANDS"
|
||||
pattern: "SLASH_COMMANDS"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Slash command popover (INPUT-05) and @mention popover (INPUT-06). These are standalone components that will be wired into ChatInput in Plan 05. Also creates the slash command routing utility that ChatPanel will use to resolve agent from message content.
|
||||
|
||||
Purpose: Enable slash commands and @mentions for routing messages to specific agents.
|
||||
Output: ChatSlashCommandPopover, ChatMentionPopover components; slash-commands utility with routing logic.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
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 ui/src/components/AgentIconPicker.tsx:
|
||||
```typescript
|
||||
export function AgentIcon({ icon, className }: { icon?: string | null; className?: string }): JSX.Element;
|
||||
```
|
||||
|
||||
From packages/shared types:
|
||||
```typescript
|
||||
interface Agent { id: string; name: string; role: AgentRole; icon: string | null; /* ... */ }
|
||||
```
|
||||
|
||||
From 22-RESEARCH.md Pattern 5:
|
||||
```typescript
|
||||
const SLASH_COMMAND_ROUTES: Record<string, AgentRole | null> = {
|
||||
"/brainstorm": "general",
|
||||
"/ask-pm": "pm",
|
||||
"/ask-engineer": "engineer",
|
||||
"/task": "pm",
|
||||
"/search": null, // Phase 22 stub
|
||||
};
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Slash command routing utility and ChatSlashCommandPopover</name>
|
||||
<read_first>
|
||||
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 122-140 slash commands)
|
||||
- .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 5 slash command routing)
|
||||
- ui/src/components/ChatInput.tsx
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/lib/slash-commands.ts,
|
||||
ui/src/components/ChatSlashCommandPopover.tsx,
|
||||
ui/src/components/ChatSlashCommandPopover.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**1. Create `ui/src/lib/slash-commands.ts`:**
|
||||
|
||||
```typescript
|
||||
import type { AgentRole } from "@paperclipai/shared";
|
||||
|
||||
export interface SlashCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
routesTo: AgentRole | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ command: "/brainstorm", description: "Route to Brainstormer", routesTo: "general" },
|
||||
{ command: "/ask-pm", description: "Route to PM", routesTo: "pm" },
|
||||
{ command: "/ask-engineer", description: "Route to Engineer", routesTo: "engineer" },
|
||||
{ command: "/task", description: "Create a task", routesTo: "pm" },
|
||||
{ command: "/search", description: "Search conversations", routesTo: null, disabled: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves which agent should receive a message based on slash command prefix or @mention.
|
||||
* Returns the agent ID to route to, or the active agent ID if no routing override found.
|
||||
*/
|
||||
export function resolveAgentFromContent(
|
||||
content: string,
|
||||
agents: Array<{ id: string; name: string; role: string }>,
|
||||
activeAgentId: string | null,
|
||||
): string | null {
|
||||
// Slash command takes highest priority
|
||||
const slashMatch = content.match(/^(\/\S+)/);
|
||||
if (slashMatch) {
|
||||
const cmd = slashMatch[1];
|
||||
const slashCmd = SLASH_COMMANDS.find((c) => c.command === cmd);
|
||||
if (slashCmd?.routesTo) {
|
||||
const agent = agents.find((a) => a.role === slashCmd.routesTo);
|
||||
if (agent) return agent.id;
|
||||
}
|
||||
}
|
||||
// @mention takes second priority
|
||||
const mentionMatch = content.match(/@(\S+)/);
|
||||
if (mentionMatch) {
|
||||
const name = mentionMatch[1]!.toLowerCase();
|
||||
const agent = agents.find((a) => a.name.toLowerCase().startsWith(name));
|
||||
if (agent) return agent.id;
|
||||
}
|
||||
return activeAgentId;
|
||||
}
|
||||
```
|
||||
|
||||
**2. Create `ui/src/components/ChatSlashCommandPopover.tsx`:**
|
||||
|
||||
```typescript
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { SLASH_COMMANDS, type SlashCommand } from "../lib/slash-commands";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ChatSlashCommandPopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (command: string) => void;
|
||||
query: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChatSlashCommandPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
query,
|
||||
children,
|
||||
}: ChatSlashCommandPopoverProps) {
|
||||
const filtered = SLASH_COMMANDS.filter((cmd) =>
|
||||
cmd.command.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[260px] p-0"
|
||||
align="start"
|
||||
side="top"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching commands</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd.command}
|
||||
disabled={cmd.disabled}
|
||||
onSelect={() => {
|
||||
if (!cmd.disabled) {
|
||||
onSelect(cmd.command);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
className={cn("flex flex-col items-start", cmd.disabled && "opacity-50")}
|
||||
>
|
||||
<span className="text-sm font-medium">{cmd.command}</span>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{cmd.description}
|
||||
{cmd.disabled && " (Coming soon)"}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per UI spec: 260px wide, opens upward (`side="top"`), items show command + description, `/search` greyed out with "Coming soon" suffix. `onOpenAutoFocus` prevented so textarea keeps focus.
|
||||
|
||||
**3. Replace test stubs in `ui/src/components/ChatSlashCommandPopover.test.tsx`:**
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SLASH_COMMANDS, resolveAgentFromContent } from "../lib/slash-commands";
|
||||
|
||||
describe("slash-commands", () => {
|
||||
it("defines 5 slash commands", () => {
|
||||
expect(SLASH_COMMANDS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("/search is disabled", () => {
|
||||
const search = SLASH_COMMANDS.find((c) => c.command === "/search");
|
||||
expect(search?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("resolveAgentFromContent routes /ask-pm to pm agent", () => {
|
||||
const agents = [
|
||||
{ id: "a1", name: "PM", role: "pm" },
|
||||
{ id: "a2", name: "Eng", role: "engineer" },
|
||||
];
|
||||
expect(resolveAgentFromContent("/ask-pm hello", agents, null)).toBe("a1");
|
||||
});
|
||||
|
||||
it("resolveAgentFromContent routes @mention to matching agent", () => {
|
||||
const agents = [
|
||||
{ id: "a1", name: "PM Agent", role: "pm" },
|
||||
{ id: "a2", name: "Engineer", role: "engineer" },
|
||||
];
|
||||
expect(resolveAgentFromContent("Hey @engineer help", agents, null)).toBe("a2");
|
||||
});
|
||||
|
||||
it("resolveAgentFromContent returns activeAgentId when no match", () => {
|
||||
const agents = [{ id: "a1", name: "PM", role: "pm" }];
|
||||
expect(resolveAgentFromContent("just a message", agents, "fallback-id")).toBe("fallback-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSlashCommandPopover", () => {
|
||||
it("exports ChatSlashCommandPopover component", async () => {
|
||||
const mod = await import("./ChatSlashCommandPopover");
|
||||
expect(mod.ChatSlashCommandPopover).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "SLASH_COMMANDS" ui/src/lib/slash-commands.ts
|
||||
- grep -q "resolveAgentFromContent" ui/src/lib/slash-commands.ts
|
||||
- grep -q "/brainstorm" ui/src/lib/slash-commands.ts
|
||||
- grep -q "/search" ui/src/lib/slash-commands.ts
|
||||
- grep -q "Coming soon" ui/src/components/ChatSlashCommandPopover.tsx
|
||||
- grep -q "w-\[260px\]" ui/src/components/ChatSlashCommandPopover.tsx
|
||||
- grep -q "side=\"top\"" ui/src/components/ChatSlashCommandPopover.tsx
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- SLASH_COMMANDS array with 5 commands, /search disabled
|
||||
- resolveAgentFromContent resolves slash commands first, then @mentions, then falls back to active agent
|
||||
- ChatSlashCommandPopover renders 260px popover opening upward with command list
|
||||
- Disabled commands shown greyed with "Coming soon"
|
||||
- Tests pass for routing logic
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ChatMentionPopover component</name>
|
||||
<read_first>
|
||||
- ui/src/components/AgentIconPicker.tsx
|
||||
- ui/src/lib/agent-role-colors.ts
|
||||
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 139-148 mention popover)
|
||||
- ui/src/components/ChatMentionPopover.test.tsx
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatMentionPopover.tsx,
|
||||
ui/src/components/ChatMentionPopover.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**1. Create `ui/src/components/ChatMentionPopover.tsx`:**
|
||||
|
||||
```typescript
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
|
||||
import type { Agent, AgentRole } from "@paperclipai/shared";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ChatMentionPopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (agentName: string) => void;
|
||||
query: string;
|
||||
agents: Agent[];
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChatMentionPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
query,
|
||||
agents,
|
||||
isLoading,
|
||||
children,
|
||||
}: ChatMentionPopoverProps) {
|
||||
const filtered = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
side="top"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="space-y-1 p-1">
|
||||
<Skeleton className="h-7 w-full" />
|
||||
<Skeleton className="h-7 w-full" />
|
||||
<Skeleton className="h-7 w-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No agents found</p>
|
||||
) : (
|
||||
<div className="max-h-[180px] overflow-auto">
|
||||
{filtered.slice(0, 5).map((agent) => {
|
||||
const colorClass = agent.role
|
||||
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
|
||||
: agentRoleColorDefault;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 w-full rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(agent.name);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per UI spec: 200px wide, opens upward, each row has icon (14x14, `h-3.5 w-3.5`) + name + role label in muted text, max 5 visible, "No agents found" empty state, 3 skeleton rows loading state. `onOpenAutoFocus` prevented to keep textarea focus.
|
||||
|
||||
**2. Replace test stubs in `ui/src/components/ChatMentionPopover.test.tsx`:**
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("ChatMentionPopover", () => {
|
||||
it("exports ChatMentionPopover component", async () => {
|
||||
const mod = await import("./ChatMentionPopover");
|
||||
expect(mod.ChatMentionPopover).toBeDefined();
|
||||
});
|
||||
|
||||
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 agent name on item click");
|
||||
it.todo("shows 'No agents found' when filter matches nothing");
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "ChatMentionPopover" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "No agents found" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "w-\[200px\]" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "side=\"top\"" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "agentRoleColors" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "onSelect" ui/src/components/ChatMentionPopover.tsx
|
||||
- grep -q "Skeleton" ui/src/components/ChatMentionPopover.tsx
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- ChatMentionPopover renders 200px upward popover with agent list
|
||||
- Agents filtered by query (case-insensitive)
|
||||
- Each row shows icon (with role color), name (truncated), and role label (muted)
|
||||
- Max 5 agents visible before scroll
|
||||
- "No agents found" empty state
|
||||
- 3 skeleton rows loading state
|
||||
- Selecting an agent calls onSelect(agentName) and closes popover
|
||||
- TypeScript compiles clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
||||
- `pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose` passes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 5 slash commands available with /search greyed out (INPUT-05)
|
||||
- @mention popover shows agents filtered by query (INPUT-06)
|
||||
- resolveAgentFromContent routes messages based on /command or @mention
|
||||
- Both popovers open upward, anchored to textarea, with proper widths
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`
|
||||
</output>
|
||||
833
.planning/phases/22-agent-streaming/22-05-PLAN.md
Normal file
833
.planning/phases/22-agent-streaming/22-05-PLAN.md
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
---
|
||||
phase: 22-agent-streaming
|
||||
plan: "05"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["22-01", "22-02", "22-03", "22-04"]
|
||||
files_modified:
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/components/ChatPanel.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/api/chat.ts
|
||||
- ui/src/components/ChatMessageList.test.tsx
|
||||
autonomous: false
|
||||
requirements:
|
||||
- PERF-03
|
||||
- CHAT-01
|
||||
- CHAT-08
|
||||
- CHAT-10
|
||||
- CHAT-11
|
||||
- CHAT-12
|
||||
- INPUT-05
|
||||
- INPUT-06
|
||||
- PERF-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "Messages render through a virtualized list with only visible items in the DOM"
|
||||
- "Streaming message appended as synthetic entry in the virtualizer"
|
||||
- "ChatPanel integrates agent selector, stop button, streaming, edit/retry, slash commands, and @mentions"
|
||||
- "User can send a message and see tokens appear in real time"
|
||||
- "User can stop, edit, or retry messages"
|
||||
- "Slash commands and @mentions route to the correct agent"
|
||||
artifacts:
|
||||
- path: "ui/src/components/ChatMessageList.tsx"
|
||||
provides: "Virtualized message list with streaming message overlay"
|
||||
contains: "useVirtualizer"
|
||||
- path: "ui/src/components/ChatPanel.tsx"
|
||||
provides: "Fully wired ChatPanel with all Phase 22 features"
|
||||
contains: "useStreamingChat"
|
||||
- path: "ui/src/components/ChatInput.tsx"
|
||||
provides: "ChatInput with slash command and @mention popovers"
|
||||
contains: "ChatSlashCommandPopover"
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/hooks/useStreamingChat.ts"
|
||||
via: "import useStreamingChat"
|
||||
pattern: "useStreamingChat"
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/components/ChatAgentSelector.tsx"
|
||||
via: "import ChatAgentSelector"
|
||||
pattern: "ChatAgentSelector"
|
||||
- from: "ui/src/components/ChatPanel.tsx"
|
||||
to: "ui/src/components/ChatStopButton.tsx"
|
||||
via: "import ChatStopButton"
|
||||
pattern: "ChatStopButton"
|
||||
- from: "ui/src/components/ChatMessageList.tsx"
|
||||
to: "@tanstack/react-virtual"
|
||||
via: "useVirtualizer"
|
||||
pattern: "useVirtualizer"
|
||||
- from: "ui/src/components/ChatInput.tsx"
|
||||
to: "ui/src/components/ChatSlashCommandPopover.tsx"
|
||||
via: "import ChatSlashCommandPopover"
|
||||
pattern: "ChatSlashCommandPopover"
|
||||
- from: "ui/src/components/ChatInput.tsx"
|
||||
to: "ui/src/components/ChatMentionPopover.tsx"
|
||||
via: "import ChatMentionPopover"
|
||||
pattern: "ChatMentionPopover"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Final integration plan: virtualize the message list (PERF-03), wire all Phase 22 components into ChatPanel and ChatInput, and add edit/retry API methods to chatApi. This plan connects every piece built in Plans 01-04 into a working end-to-end experience.
|
||||
|
||||
Purpose: Deliver the complete Phase 22 feature set as a wired, working system.
|
||||
Output: Virtualized ChatMessageList, fully integrated ChatPanel, ChatInput with popovers, chat API edit/truncate methods.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
@.planning/phases/22-agent-streaming/22-03-SUMMARY.md
|
||||
@.planning/phases/22-agent-streaming/22-04-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
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/components/ChatAgentSelector.tsx (Plan 02):
|
||||
```typescript
|
||||
interface ChatAgentSelectorProps {
|
||||
companyId: string;
|
||||
conversationId: string | null;
|
||||
agentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
}
|
||||
export function ChatAgentSelector(props: ChatAgentSelectorProps): JSX.Element;
|
||||
```
|
||||
|
||||
From ui/src/components/ChatStopButton.tsx (Plan 03):
|
||||
```typescript
|
||||
export function ChatStopButton({ onStop }: { onStop: () => void }): JSX.Element;
|
||||
```
|
||||
|
||||
From ui/src/components/ChatMessage.tsx (Plan 03):
|
||||
```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;
|
||||
onEdit?: (messageId: string, newContent: string) => void;
|
||||
onRetry?: (messageId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/components/ChatSlashCommandPopover.tsx (Plan 04):
|
||||
```typescript
|
||||
interface ChatSlashCommandPopoverProps {
|
||||
open: boolean; onOpenChange: (open: boolean) => void;
|
||||
onSelect: (command: string) => void; query: string; children: React.ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/components/ChatMentionPopover.tsx (Plan 04):
|
||||
```typescript
|
||||
interface ChatMentionPopoverProps {
|
||||
open: boolean; onOpenChange: (open: boolean) => void;
|
||||
onSelect: (agentName: string) => void; query: string;
|
||||
agents: Agent[]; isLoading?: boolean; children: React.ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
From ui/src/lib/slash-commands.ts (Plan 04):
|
||||
```typescript
|
||||
export function resolveAgentFromContent(
|
||||
content: string,
|
||||
agents: Array<{ id: string; name: string; role: string }>,
|
||||
activeAgentId: string | null,
|
||||
): string | null;
|
||||
```
|
||||
|
||||
From ui/src/hooks/useChatMessages.ts:
|
||||
```typescript
|
||||
export function useChatMessages(conversationId: string | null): {
|
||||
messages: ChatMessage[]; isLoading: boolean; sendMutation: UseMutationResult;
|
||||
// ... infinite query props
|
||||
};
|
||||
```
|
||||
|
||||
From ui/src/api/chat.ts:
|
||||
```typescript
|
||||
export const chatApi = {
|
||||
listConversations, createConversation, getConversation,
|
||||
updateConversation, deleteConversation, listMessages, postMessage,
|
||||
postMessageAndStream, savePartialMessage,
|
||||
};
|
||||
```
|
||||
|
||||
From server routes (Plan 01):
|
||||
```
|
||||
PATCH /conversations/:id/messages/:msgId
|
||||
DELETE /conversations/:id/messages/after/:msgId
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Virtualized ChatMessageList and chat API edit/truncate methods</name>
|
||||
<read_first>
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/hooks/useChatMessages.ts
|
||||
- ui/src/api/chat.ts
|
||||
- .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 3 virtualizer)
|
||||
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 341-349 virtualizer)
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatMessageList.tsx,
|
||||
ui/src/api/chat.ts,
|
||||
ui/src/components/ChatMessageList.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
**1. Add edit and truncate methods to `chatApi` in `ui/src/api/chat.ts`:**
|
||||
|
||||
```typescript
|
||||
async editMessage(conversationId: string, messageId: string, content: string) {
|
||||
return api.patch<ChatMessage>(`/conversations/${conversationId}/messages/${messageId}`, { content });
|
||||
},
|
||||
|
||||
async truncateMessagesAfter(conversationId: string, messageId: string) {
|
||||
await fetch(`/api/conversations/${conversationId}/messages/after/${messageId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
**2. Rewrite `ui/src/components/ChatMessageList.tsx` with virtualizer:**
|
||||
|
||||
Replace the entire file with a virtualized implementation:
|
||||
|
||||
```typescript
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useChatMessages } from "../hooks/useChatMessages";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ChatMessage as ChatMessageType, AgentRole } from "@paperclipai/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
conversationId: string;
|
||||
streamingContent?: string;
|
||||
isStreaming?: boolean;
|
||||
streamingAgentName?: string | null;
|
||||
streamingAgentIcon?: string | null;
|
||||
streamingAgentRole?: AgentRole | null;
|
||||
onEdit?: (messageId: string, newContent: string) => void;
|
||||
onRetry?: (messageId: string) => void;
|
||||
agentMap?: Map<string, { name: string; icon: string | null; role: AgentRole | null }>;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
conversationId,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
streamingAgentName,
|
||||
streamingAgentIcon,
|
||||
streamingAgentRole,
|
||||
onEdit,
|
||||
onRetry,
|
||||
agentMap,
|
||||
}: ChatMessageListProps) {
|
||||
const { messages, isLoading } = useChatMessages(conversationId);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
||||
|
||||
// Build display list: real messages + optional synthetic streaming message
|
||||
const displayMessages: Array<ChatMessageType & { isStreamingEntry?: boolean }> = [
|
||||
...messages,
|
||||
...(isStreaming && streamingContent
|
||||
? [{
|
||||
id: "__streaming__",
|
||||
conversationId,
|
||||
role: "assistant" as const,
|
||||
content: streamingContent,
|
||||
agentId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
isStreamingEntry: true,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: displayMessages.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 80,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (if user hasn't scrolled up)
|
||||
useEffect(() => {
|
||||
if (displayMessages.length > 0 && !showJumpToBottom) {
|
||||
virtualizer.scrollToIndex(displayMessages.length - 1, { align: "end" });
|
||||
}
|
||||
}, [displayMessages.length]);
|
||||
|
||||
// Re-measure streaming message as it grows (Pitfall 3 from RESEARCH.md)
|
||||
useEffect(() => {
|
||||
if (isStreaming && displayMessages.length > 0) {
|
||||
virtualizer.measure();
|
||||
}
|
||||
}, [streamingContent, isStreaming]);
|
||||
|
||||
// Track scroll position for "jump to bottom" button
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setShowJumpToBottom(distFromBottom > 200);
|
||||
}, []);
|
||||
|
||||
const jumpToBottom = () => {
|
||||
virtualizer.scrollToIndex(displayMessages.length - 1, { align: "end" });
|
||||
setShowJumpToBottom(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<Skeleton className="h-16 w-3/4" />
|
||||
<Skeleton className="h-12 w-1/2 ml-auto" />
|
||||
<Skeleton className="h-20 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMessages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto p-3"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const msg = displayMessages[item.index]!;
|
||||
const agent = msg.agentId && agentMap ? agentMap.get(msg.agentId) : undefined;
|
||||
const isThisStreaming = "isStreamingEntry" in msg && msg.isStreamingEntry;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${item.start}px)`,
|
||||
width: "100%",
|
||||
paddingBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<ChatMessage
|
||||
id={msg.id}
|
||||
role={msg.role as "user" | "assistant" | "system"}
|
||||
content={msg.content}
|
||||
agentName={agent?.name ?? streamingAgentName}
|
||||
agentIcon={agent?.icon ?? streamingAgentIcon}
|
||||
agentRole={agent?.role ?? streamingAgentRole}
|
||||
timestamp={msg.createdAt}
|
||||
isStreaming={isThisStreaming}
|
||||
isAnyStreaming={isStreaming}
|
||||
onEdit={onEdit}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jump to bottom button */}
|
||||
{showJumpToBottom && (
|
||||
<div className="absolute bottom-2 right-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full shadow-md"
|
||||
onClick={jumpToBottom}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `useVirtualizer` with `estimateSize: 80`, `overscan: 5`, dynamic measurement via `measureElement`
|
||||
- Streaming message appended as synthetic entry with `id: "__streaming__"` and `isStreamingEntry: true`
|
||||
- `virtualizer.measure()` called on `streamingContent` change to re-measure growing message (Pitfall 3)
|
||||
- "Jump to bottom" button when scrolled >200px from bottom
|
||||
- 3 loading skeletons with varying widths
|
||||
- Agent identity props resolved from `agentMap` or streaming agent props
|
||||
|
||||
**3. Update test stubs in `ui/src/components/ChatMessageList.test.tsx`:**
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("ChatMessageList", () => {
|
||||
it("exports ChatMessageList component", async () => {
|
||||
const mod = await import("./ChatMessageList");
|
||||
expect(mod.ChatMessageList).toBeDefined();
|
||||
});
|
||||
|
||||
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");
|
||||
it.todo("shows jump-to-bottom button when scrolled up");
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "useVirtualizer" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "measureElement" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "__streaming__" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "virtualizer.measure" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "Scroll to latest message" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "estimateSize" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "overscan" ui/src/components/ChatMessageList.tsx
|
||||
- grep -q "editMessage" ui/src/api/chat.ts
|
||||
- grep -q "truncateMessagesAfter" ui/src/api/chat.ts
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- ChatMessageList uses @tanstack/react-virtual useVirtualizer
|
||||
- Dynamic height measurement via measureElement
|
||||
- Streaming message rendered as synthetic array entry
|
||||
- virtualizer.measure() called on streaming content change
|
||||
- Jump-to-bottom button appears when scrolled >200px from bottom
|
||||
- 3 loading skeletons shown during load
|
||||
- chatApi has editMessage and truncateMessagesAfter methods
|
||||
- TypeScript compiles clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire ChatPanel and ChatInput with all Phase 22 features</name>
|
||||
<read_first>
|
||||
- ui/src/components/ChatPanel.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/components/ChatMessageList.tsx
|
||||
- ui/src/hooks/useStreamingChat.ts
|
||||
- ui/src/components/ChatAgentSelector.tsx
|
||||
- ui/src/components/ChatStopButton.tsx
|
||||
- ui/src/components/ChatSlashCommandPopover.tsx
|
||||
- ui/src/components/ChatMentionPopover.tsx
|
||||
- ui/src/lib/slash-commands.ts
|
||||
- ui/src/api/agents.ts
|
||||
</read_first>
|
||||
<files>
|
||||
ui/src/components/ChatPanel.tsx,
|
||||
ui/src/components/ChatInput.tsx
|
||||
</files>
|
||||
<action>
|
||||
**1. Rewrite `ui/src/components/ChatInput.tsx` to add slash command and @mention popovers:**
|
||||
|
||||
Keep the existing textarea, auto-resize, and keyboard handling. Add:
|
||||
|
||||
a) Import `ChatSlashCommandPopover`, `ChatMentionPopover`, `Agent` type.
|
||||
b) Add new props:
|
||||
```typescript
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
// Popover support
|
||||
agents?: Agent[];
|
||||
agentsLoading?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
c) Add state for popovers:
|
||||
```typescript
|
||||
const [slashOpen, setSlashOpen] = useState(false);
|
||||
const [slashQuery, setSlashQuery] = useState("");
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
```
|
||||
|
||||
d) Update the `onChange` handler to detect `/` and `@`:
|
||||
```typescript
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const val = e.target.value;
|
||||
setValue(val);
|
||||
|
||||
// Slash command: opens when / is the first character
|
||||
if (val.startsWith("/")) {
|
||||
setSlashOpen(true);
|
||||
setSlashQuery(val);
|
||||
} else {
|
||||
setSlashOpen(false);
|
||||
}
|
||||
|
||||
// @mention: opens when @ appears with a word boundary before it
|
||||
const mentionMatch = val.match(/@(\w*)$/);
|
||||
if (mentionMatch) {
|
||||
setMentionOpen(true);
|
||||
setMentionQuery(mentionMatch[1] ?? "");
|
||||
} else {
|
||||
setMentionOpen(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
e) Handle slash command selection:
|
||||
```typescript
|
||||
function handleSlashSelect(command: string) {
|
||||
setValue(command + " ");
|
||||
setSlashOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
```
|
||||
|
||||
f) Handle mention selection:
|
||||
```typescript
|
||||
function handleMentionSelect(agentName: string) {
|
||||
// Replace the @query with @agentName
|
||||
const val = value.replace(/@\w*$/, `@${agentName} `);
|
||||
setValue(val);
|
||||
setMentionOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
```
|
||||
|
||||
g) Wrap the form in a relative div and add popover components:
|
||||
- `ChatSlashCommandPopover` wraps the textarea as trigger
|
||||
- `ChatMentionPopover` wraps the textarea as trigger
|
||||
- Use only ONE popover active at a time (slash takes priority)
|
||||
|
||||
Per UI spec: popovers open upward from textarea, dismissed on Escape or clicking outside. Placeholder changes to "Waiting for response..." when disabled (per `placeholder` prop).
|
||||
|
||||
**2. Rewrite `ui/src/components/ChatPanel.tsx` to integrate all Phase 22 features:**
|
||||
|
||||
The new ChatPanel needs:
|
||||
- `useStreamingChat` hook for streaming
|
||||
- `ChatAgentSelector` in header
|
||||
- `ChatStopButton` above input when streaming
|
||||
- Agent resolution from slash commands / @mentions
|
||||
- Edit and retry handlers
|
||||
- Agent data for identity bars
|
||||
|
||||
```typescript
|
||||
import { useState, useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatConversationList } from "./ChatConversationList";
|
||||
import { ChatMessageList } from "./ChatMessageList";
|
||||
import { ChatAgentSelector } from "./ChatAgentSelector";
|
||||
import { ChatStopButton } from "./ChatStopButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { chatApi } from "../api/chat";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useChatMessages } from "../hooks/useChatMessages";
|
||||
import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||
import type { AgentRole } from "@paperclipai/shared";
|
||||
|
||||
export function ChatPanel() {
|
||||
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
|
||||
const { sendMutation } = useChatMessages(activeConversationId);
|
||||
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
||||
|
||||
// Load agents for routing and identity
|
||||
const { data: agents = [], isLoading: agentsLoading } = useQuery({
|
||||
queryKey: ["agents", selectedCompanyId],
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Build agent map for message identity bars
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; icon: string | null; role: AgentRole | null }>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null });
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
// Resolve streaming agent identity
|
||||
const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined;
|
||||
|
||||
const handleSend = async (content: string) => {
|
||||
if (!selectedCompanyId) return;
|
||||
|
||||
// Resolve agent from slash command or @mention
|
||||
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (!activeConversationId) {
|
||||
// Path 1: No active conversation — create one, post user message, then stream
|
||||
const newConvo = await chatApi.createConversation(selectedCompanyId, {
|
||||
agentId: resolvedAgentId ?? undefined,
|
||||
});
|
||||
setActiveConversationId(newConvo.id);
|
||||
await chatApi.postMessage(newConvo.id, { role: "user", content });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat"] });
|
||||
// Note: streaming starts on next render when activeConversationId is set
|
||||
// For now, the echo stream will be triggered by the new conversation
|
||||
} else {
|
||||
// Path 2: Active conversation — post user message then stream
|
||||
await chatApi.postMessage(activeConversationId, { role: "user", content });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||
startStream(content, resolvedAgentId ?? undefined);
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit handler: update message, truncate after it, re-stream
|
||||
const handleEdit = async (messageId: string, newContent: string) => {
|
||||
if (!activeConversationId) return;
|
||||
await chatApi.editMessage(activeConversationId, messageId, newContent);
|
||||
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||
startStream(newContent, activeAgentId ?? undefined);
|
||||
};
|
||||
|
||||
// Retry handler: truncate from the assistant message onward, re-stream the previous user message
|
||||
const handleRetry = async (messageId: string) => {
|
||||
if (!activeConversationId) return;
|
||||
// Truncate the assistant message and everything after
|
||||
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
|
||||
// Also delete the message itself
|
||||
// For retry, we re-stream using the last user message content
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||
// The last user message is the one before the deleted assistant message
|
||||
// Re-trigger stream with empty content (server echo will use whatever was sent)
|
||||
startStream("Regenerate this response", activeAgentId ?? undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Chat"
|
||||
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
||||
style={{ width: chatOpen ? 380 : 0 }}
|
||||
>
|
||||
{/* Header with agent selector */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
||||
<span className="text-sm font-medium">Chat</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCompanyId && (
|
||||
<ChatAgentSelector
|
||||
companyId={selectedCompanyId}
|
||||
conversationId={activeConversationId}
|
||||
agentId={activeAgentId}
|
||||
onAgentChange={setActiveAgentId}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setChatOpen(false)}
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
||||
{/* Left column: conversation list */}
|
||||
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
||||
{selectedCompanyId ? (
|
||||
<ChatConversationList companyId={selectedCompanyId} />
|
||||
) : (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
No workspace selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: message thread + stop button + input */}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
{/* Message area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeConversationId ? (
|
||||
<ChatMessageList
|
||||
conversationId={activeConversationId}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
streamingAgentName={streamingAgent?.name ?? null}
|
||||
streamingAgentIcon={streamingAgent?.icon ?? null}
|
||||
streamingAgentRole={streamingAgent?.role ?? null}
|
||||
onEdit={handleEdit}
|
||||
onRetry={handleRetry}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Send a message to start this conversation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop button (shown during streaming) */}
|
||||
{isStreaming && <ChatStopButton onStop={stop} />}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isSubmitting={isSending}
|
||||
disabled={isStreaming}
|
||||
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
||||
agents={agents}
|
||||
agentsLoading={agentsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key integration points:
|
||||
- `useStreamingChat` provides streamingContent, isStreaming, startStream, stop
|
||||
- `ChatAgentSelector` in header with `onAgentChange` updating local state
|
||||
- `ChatStopButton` shown conditionally when `isStreaming`
|
||||
- `ChatInput` receives `agents` for mention popover, `disabled` during streaming, custom placeholder
|
||||
- `ChatMessageList` receives streaming props and agentMap for identity bars
|
||||
- `handleEdit` calls editMessage + truncateMessagesAfter + startStream
|
||||
- `handleRetry` calls truncateMessagesAfter + startStream
|
||||
- `resolveAgentFromContent` determines which agent receives the message
|
||||
- `ScrollArea` replaced by virtualizer's own scroll container in ChatMessageList
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "useStreamingChat" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "ChatAgentSelector" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "ChatStopButton" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "resolveAgentFromContent" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "handleEdit" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "handleRetry" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "agentMap" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "streamingContent" ui/src/components/ChatPanel.tsx
|
||||
- grep -q "ChatSlashCommandPopover" ui/src/components/ChatInput.tsx
|
||||
- grep -q "ChatMentionPopover" ui/src/components/ChatInput.tsx
|
||||
- grep -q "slashOpen" ui/src/components/ChatInput.tsx
|
||||
- grep -q "mentionOpen" ui/src/components/ChatInput.tsx
|
||||
- grep -q "placeholder" ui/src/components/ChatInput.tsx
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
- ChatPanel integrates: useStreamingChat, ChatAgentSelector, ChatStopButton, agent routing, edit/retry handlers
|
||||
- ChatInput has slash command popover (triggered by / at start) and @mention popover (triggered by @)
|
||||
- Streaming content passed to ChatMessageList as synthetic entry
|
||||
- Agent identity resolved from agentMap for message identity bars
|
||||
- Edit handler: editMessage + truncateMessagesAfter + re-stream
|
||||
- Retry handler: truncateMessagesAfter + re-stream
|
||||
- Input disabled during streaming with "Waiting for response..." placeholder
|
||||
- Stop button appears during streaming
|
||||
- Agent selector in header for per-conversation agent switching
|
||||
- TypeScript compiles clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Verify complete Phase 22 feature set</name>
|
||||
<what-built>Complete Phase 22 agent streaming feature: SSE streaming with echo stub, agent selector, message identity bars with role colors, edit/retry/stop controls, slash commands, @mentions, and virtualized message list.</what-built>
|
||||
<how-to-verify>
|
||||
1. Start the dev server: `pnpm dev`
|
||||
2. Open the chat panel (click chat icon in sidebar)
|
||||
3. Send a message — verify tokens stream in word-by-word (echo stub)
|
||||
4. During streaming, verify the blinking cursor appears at the end
|
||||
5. Click "Stop generating" during a stream — verify partial message saved with [stopped] suffix
|
||||
6. Hover a user message — verify edit pencil appears; click it, edit, save — verify response regenerates
|
||||
7. Hover an assistant message — verify retry button appears; click — verify regeneration
|
||||
8. Use the agent selector in the header to switch agents
|
||||
9. Type `/` at start of input — verify slash command popover opens; select `/ask-pm`
|
||||
10. Type `@` in input — verify agent mention popover opens
|
||||
11. Verify agent name and colored icon appear above assistant messages
|
||||
12. Switch between all 3 themes — verify agent colors remain distinguishable
|
||||
13. Load a conversation with many messages — verify smooth scrolling (virtualized)
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues</resume-signal>
|
||||
<action>Human verification of the complete Phase 22 feature set. Follow the how-to-verify steps above.</action>
|
||||
<verify><automated>pnpm --filter @paperclipai/ui vitest run --reporter=verbose</automated></verify>
|
||||
<done>All 13 verification steps pass visual/functional inspection</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
||||
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
|
||||
- All components wired and rendering
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tokens stream from server to client in real time (CHAT-01, PERF-02)
|
||||
- Agent selector allows switching active agent (CHAT-08)
|
||||
- Edit previous message triggers regeneration (CHAT-10)
|
||||
- Retry button regenerates assistant response (CHAT-11)
|
||||
- Stop button cancels streaming and preserves partial content (CHAT-12)
|
||||
- Slash commands route to correct agent (INPUT-05)
|
||||
- @mentions route to named agent (INPUT-06)
|
||||
- Agent identity bar with role colors on every assistant message (AGENT-04, THEME-03)
|
||||
- 1,000+ messages scroll via virtualized list (PERF-03)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/22-agent-streaming/22-05-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue