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", () => { 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 user message as right-aligned bubble with plain text");
it.todo("renders assistant message with ChatMarkdownMessage"); it.todo("renders assistant message with ChatMarkdownMessage");
it.todo("renders ChatMessageIdentityBar for assistant messages when agentName is provided"); it.todo("renders ChatMessageIdentityBar for assistant messages when agentName is provided");
it.todo("shows edit pencil on hover for user messages"); it.todo("shows edit pencil on hover for user messages");
it.todo("shows retry button on hover for assistant 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("switches to inline edit textarea on pencil click");
it.todo("renders ChatStreamingCursor when isStreaming is true"); 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 { ChatMarkdownMessage } from "./ChatMarkdownMessage";
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar"; import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
import { ChatStreamingCursor } from "./ChatStreamingCursor"; import { ChatStreamingCursor } from "./ChatStreamingCursor";
import { ChatMessageActions } from "./ChatMessageActions";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import type { AgentRole } from "@paperclipai/shared"; import type { AgentRole } from "@paperclipai/shared";
interface ChatMessageProps { interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
content: string; content: string;
agentName?: string | null; agentName?: string | null;
@ -12,9 +16,13 @@ interface ChatMessageProps {
agentRole?: AgentRole | null; agentRole?: AgentRole | null;
timestamp?: string; timestamp?: string;
isStreaming?: boolean; isStreaming?: boolean;
isAnyStreaming?: boolean;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
} }
export function ChatMessage({ export function ChatMessage({
id,
role, role,
content, content,
agentName, agentName,
@ -22,20 +30,73 @@ export function ChatMessage({
agentRole, agentRole,
timestamp, timestamp,
isStreaming, isStreaming,
isAnyStreaming,
onEdit,
onRetry,
}: ChatMessageProps) { }: ChatMessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(content);
if (role === "user") { 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 ( return (
<div className="flex justify-end"> <div className="flex justify-end">
<div <div
className={cn( 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} {content}
<ChatMessageActions
role="user"
isStreaming={isAnyStreaming}
onEdit={() => setIsEditing(true)}
/>
</div> </div>
</div> </div>
); );
} }
// assistant or system // assistant or system
return ( return (
<div className="max-w-full group relative"> <div className="max-w-full group relative">
@ -50,6 +111,11 @@ export function ChatMessage({
)} )}
<ChatMarkdownMessage content={content} /> <ChatMarkdownMessage content={content} />
{isStreaming && <ChatStreamingCursor />} {isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"
isStreaming={isAnyStreaming}
onRetry={id && onRetry ? () => onRetry(id) : undefined}
/>
</div> </div>
); );
} }