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:
Nexus Dev 2026-04-01 18:14:20 +00:00
parent 816945bfb5
commit cf73f3481c
4 changed files with 137 additions and 7 deletions

View file

@ -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>
);
}

View file

@ -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();
});
});

View 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>
);
}

View 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"
/>
);
}