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