feat(22-02): ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage
- Create ChatMessageIdentityBar with agent icon, name, timestamp, and streaming dot - Create ChatStreamingCursor with animate-cursor-blink and aria-hidden - Extend ChatMessage with agentName, agentIcon, agentRole, timestamp, isStreaming props - Wrap assistant messages in group div for hover actions (Plan 03) - Create agent-role-colors.ts with 11 distinct role colors (light + dark variants) - Create ChatMarkdownMessage prerequisite from phase-21 base - All 4 ChatMessageIdentityBar tests pass
This commit is contained in:
parent
8f7264a86c
commit
704e9f2406
4 changed files with 137 additions and 7 deletions
|
|
@ -1,12 +1,28 @@
|
||||||
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
||||||
|
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
||||||
|
import { ChatStreamingCursor } from "./ChatStreamingCursor";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: string;
|
content: string;
|
||||||
|
agentName?: string | null;
|
||||||
|
agentIcon?: string | null;
|
||||||
|
agentRole?: AgentRole | null;
|
||||||
|
timestamp?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ role, content }: ChatMessageProps) {
|
export function ChatMessage({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
agentName,
|
||||||
|
agentIcon,
|
||||||
|
agentRole,
|
||||||
|
timestamp,
|
||||||
|
isStreaming,
|
||||||
|
}: ChatMessageProps) {
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -22,8 +38,18 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
|
||||||
}
|
}
|
||||||
// assistant or system
|
// assistant or system
|
||||||
return (
|
return (
|
||||||
<div className="max-w-full">
|
<div className="max-w-full group relative">
|
||||||
|
{agentName && (
|
||||||
|
<ChatMessageIdentityBar
|
||||||
|
agentName={agentName}
|
||||||
|
agentIcon={agentIcon}
|
||||||
|
agentRole={agentRole}
|
||||||
|
timestamp={timestamp}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ChatMarkdownMessage content={content} />
|
<ChatMarkdownMessage content={content} />
|
||||||
|
{isStreaming && <ChatStreamingCursor />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,66 @@
|
||||||
import { describe, it } from "vitest";
|
// @vitest-environment jsdom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
||||||
|
|
||||||
|
// Tell React this environment uses act() for event flushing.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
describe("ChatMessageIdentityBar", () => {
|
describe("ChatMessageIdentityBar", () => {
|
||||||
it.todo("renders agent icon at 16x16px");
|
let container: HTMLDivElement;
|
||||||
it.todo("renders agent name in semibold text");
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
it.todo("renders timestamp in muted text");
|
|
||||||
it.todo("applies role-specific color from agentRoleColors");
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (container.parentNode) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(props: Parameters<typeof ChatMessageIdentityBar>[0]) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(<ChatMessageIdentityBar {...props} />);
|
||||||
|
});
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders agent name in semibold text", () => {
|
||||||
|
render({ agentName: "PM Agent" });
|
||||||
|
const nameEl = container.querySelector(".font-semibold");
|
||||||
|
expect(nameEl).toBeDefined();
|
||||||
|
expect(nameEl?.textContent).toBe("PM Agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders timestamp when provided", () => {
|
||||||
|
render({ agentName: "Test", timestamp: "2026-01-01T12:30:00Z" });
|
||||||
|
const allText = container.textContent ?? "";
|
||||||
|
expect(allText).toContain("12:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies role-specific color class", () => {
|
||||||
|
render({ agentName: "PM", agentRole: "pm" });
|
||||||
|
const nameEl = container.querySelector(".font-semibold");
|
||||||
|
expect(nameEl?.className).toContain("text-blue-600");
|
||||||
|
expect(nameEl?.className).toContain("dark:text-blue-400");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows streaming indicator dot when isStreaming", () => {
|
||||||
|
render({ agentName: "Test", isStreaming: true });
|
||||||
|
const dot = container.querySelector(".animate-pulse");
|
||||||
|
expect(dot).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
38
ui/src/components/ChatMessageIdentityBar.tsx
Normal file
38
ui/src/components/ChatMessageIdentityBar.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
|
||||||
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
interface ChatMessageIdentityBarProps {
|
||||||
|
agentName: string;
|
||||||
|
agentIcon?: string | null;
|
||||||
|
agentRole?: AgentRole | null;
|
||||||
|
timestamp?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageIdentityBar({
|
||||||
|
agentName,
|
||||||
|
agentIcon,
|
||||||
|
agentRole,
|
||||||
|
timestamp,
|
||||||
|
isStreaming,
|
||||||
|
}: ChatMessageIdentityBarProps) {
|
||||||
|
const colorClass = agentRole
|
||||||
|
? (agentRoleColors[agentRole] ?? agentRoleColorDefault)
|
||||||
|
: agentRoleColorDefault;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AgentIcon icon={agentIcon} className={`h-4 w-4 ${colorClass}`} />
|
||||||
|
<span className={`text-[13px] font-semibold ${colorClass}`}>{agentName}</span>
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
{timestamp && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
ui/src/components/ChatStreamingCursor.tsx
Normal file
8
ui/src/components/ChatStreamingCursor.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function ChatStreamingCursor() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue