feat(22-03): extend ChatMessage with inline edit mode and action callbacks

- Add id, isAnyStreaming, onEdit, onRetry props to ChatMessageProps
- User messages show edit Pencil on hover via ChatMessageActions
- Edit pencil opens inline textarea with Save/Discard buttons
- Save edit calls onEdit(id, newContent), disabled when textarea empty
- Discard edit reverts to read-only bubble
- Assistant messages show retry RefreshCw via ChatMessageActions
- All edit/retry actions disabled when isAnyStreaming is true
- Update test stubs to reflect new prop surface
This commit is contained in:
Nexus Dev 2026-04-01 18:24:56 +00:00
parent d88d846b1e
commit be431c9492
2 changed files with 77 additions and 3 deletions

View file

@ -1,12 +1,20 @@
import { describe, it } from "vitest";
// @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 isStreaming is true");
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");
});

View file

@ -1,10 +1,14 @@
import { useState } from "react";
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
import { ChatStreamingCursor } from "./ChatStreamingCursor";
import { ChatMessageActions } from "./ChatMessageActions";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import type { AgentRole } from "@paperclipai/shared";
interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
@ -12,9 +16,13 @@ interface ChatMessageProps {
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
isAnyStreaming?: boolean;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
}
export function ChatMessage({
id,
role,
content,
agentName,
@ -22,20 +30,73 @@ export function ChatMessage({
agentRole,
timestamp,
isStreaming,
isAnyStreaming,
onEdit,
onRetry,
}: ChatMessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(content);
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={cn(
"max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm",
"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>
);
}
// assistant or system
return (
<div className="max-w-full group relative">
@ -50,6 +111,11 @@ export function ChatMessage({
)}
<ChatMarkdownMessage content={content} />
{isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"
isStreaming={isAnyStreaming}
onRetry={id && onRetry ? () => onRetry(id) : undefined}
/>
</div>
);
}