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", () => {
|
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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue