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:
parent
d88d846b1e
commit
be431c9492
2 changed files with 77 additions and 3 deletions
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue