14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22-agent-streaming | 03 | execute | 2 |
|
|
true |
|
|
Purpose: Give users full control over message lifecycle — edit, retry, stop. Output: ChatMessageActions, ChatStopButton components; ChatMessage with inline edit mode.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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):
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):
// chatApi methods available:
chatApi.postMessage(conversationId, data)
chatApi.updateConversation(conversationId, data)
// Plan 01 additions:
chatApi.postMessageAndStream(conversationId, data, callbacks, signal)
chatApi.savePartialMessage(conversationId, data)
From server/src/routes/chat.ts (Plan 01):
PATCH /conversations/:id/messages/:msgId — edit message content
DELETE /conversations/:id/messages/after/:msgId — truncate messages after
POST /conversations/:id/stream — SSE streaming
Task 1: ChatStopButton and ChatMessageActions components
- ui/src/components/ChatMessage.tsx
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 96-120 stop/edit/retry)
ui/src/components/ChatStopButton.tsx,
ui/src/components/ChatMessageActions.tsx
**1. Create `ui/src/components/ChatStopButton.tsx`:**
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:
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.
pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20
<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>
- 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
Add to the existing interface:
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:
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:
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:
// @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");
});
pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20
- 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
- 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
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run --reporter=verbose` passes
<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>