428 lines
14 KiB
Markdown
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>
|