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
816945bfb5
commit
cf73f3481c
4 changed files with 137 additions and 7 deletions
|
|
@ -1,12 +1,28 @@
|
|||
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
||||
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
||||
import { ChatStreamingCursor } from "./ChatStreamingCursor";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { AgentRole } from "@paperclipai/shared";
|
||||
|
||||
interface ChatMessageProps {
|
||||
role: "user" | "assistant" | "system";
|
||||
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") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -22,8 +38,18 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
|
|||
}
|
||||
// assistant or system
|
||||
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} />
|
||||
{isStreaming && <ChatStreamingCursor />}
|
||||
</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", () => {
|
||||
it.todo("renders agent icon at 16x16px");
|
||||
it.todo("renders agent name in semibold text");
|
||||
it.todo("renders timestamp in muted text");
|
||||
it.todo("applies role-specific color from agentRoleColors");
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
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