nexus/.planning/phases/22-agent-streaming/22-03-PLAN.md

428 lines
14 KiB
Markdown

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