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

14 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
22-agent-streaming 03 execute 2
22-01
22-02
ui/src/components/ChatMessageActions.tsx
ui/src/components/ChatMessage.tsx
ui/src/components/ChatStopButton.tsx
ui/src/components/ChatMessage.test.tsx
true
CHAT-10
CHAT-11
CHAT-12
truths artifacts key_links
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
path provides exports
ui/src/components/ChatMessageActions.tsx Edit and Retry hover action buttons
ChatMessageActions
path provides exports
ui/src/components/ChatStopButton.tsx Stop generating button
ChatStopButton
path provides contains
ui/src/components/ChatMessage.tsx Extended ChatMessage with edit mode, retry, actions ChatMessageActions
from to via pattern
ui/src/components/ChatMessage.tsx ui/src/components/ChatMessageActions.tsx import ChatMessageActions ChatMessageActions
from to via pattern
ui/src/components/ChatMessageActions.tsx parent callbacks onEdit, onRetry props onEdit|onRetry
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.

<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

Task 2: Extend ChatMessage with inline edit mode and wire action callbacks - 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) ui/src/components/ChatMessage.tsx, ui/src/components/ChatMessage.test.tsx **1. Extend `ChatMessageProps` in `ui/src/components/ChatMessage.tsx`:**

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>
After completion, create `.planning/phases/22-agent-streaming/22-03-SUMMARY.md`