--- phase: 22-agent-streaming plan: "02" type: execute wave: 1 depends_on: ["22-00"] files_modified: - ui/src/components/ChatAgentSelector.tsx - ui/src/components/ChatMessageIdentityBar.tsx - ui/src/components/ChatStreamingCursor.tsx - ui/src/components/ChatMessage.tsx - ui/src/components/ChatAgentSelector.test.tsx - ui/src/components/ChatMessageIdentityBar.test.tsx autonomous: true requirements: - AGENT-04 - CHAT-08 - THEME-03 must_haves: truths: - "Every assistant message shows the agent's name and icon above the content" - "User can switch the active agent for a conversation via a dropdown selector" - "Agent colors are visually distinguishable using role-specific Tailwind classes with dark: variants" artifacts: - path: "ui/src/components/ChatAgentSelector.tsx" provides: "Agent dropdown in ChatPanel header" exports: ["ChatAgentSelector"] - path: "ui/src/components/ChatMessageIdentityBar.tsx" provides: "Agent icon + name + timestamp above assistant messages" exports: ["ChatMessageIdentityBar"] - path: "ui/src/components/ChatStreamingCursor.tsx" provides: "Blinking inline cursor during streaming" exports: ["ChatStreamingCursor"] - path: "ui/src/components/ChatMessage.tsx" provides: "Extended ChatMessage with identity bar, streaming cursor, hover actions" exports: ["ChatMessage"] key_links: - from: "ui/src/components/ChatMessageIdentityBar.tsx" to: "ui/src/lib/agent-role-colors.ts" via: "import agentRoleColors" pattern: "agentRoleColors" - from: "ui/src/components/ChatAgentSelector.tsx" to: "ui/src/api/agents.ts" via: "agentsApi.list" pattern: "agentsApi" - from: "ui/src/components/ChatMessage.tsx" to: "ui/src/components/ChatMessageIdentityBar.tsx" via: "import ChatMessageIdentityBar" pattern: "ChatMessageIdentityBar" --- Agent identity components: agent selector dropdown (CHAT-08), message identity bar with icon/name/timestamp (AGENT-04), streaming cursor, and role-specific colors (THEME-03). Extends ChatMessage to accept and render agent identity props. Purpose: Make agent identity visible on every assistant message and allow users to switch agents per conversation. Output: ChatAgentSelector, ChatMessageIdentityBar, ChatStreamingCursor components; extended ChatMessage. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/22-agent-streaming/22-RESEARCH.md @.planning/phases/22-agent-streaming/22-UI-SPEC.md @.planning/phases/22-agent-streaming/22-00-SUMMARY.md From ui/src/components/AgentIconPicker.tsx: ```typescript interface AgentIconProps { icon?: string | null; className?: string; } export function AgentIcon({ icon, className }: AgentIconProps): JSX.Element; ``` From ui/src/api/agents.ts: ```typescript export const agentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/agents`), // ... }; ``` From packages/shared/src/types/chat.ts: ```typescript export interface ChatMessage { id: string; conversationId: string; role: "user" | "assistant" | "system"; content: string; agentId: string | null; createdAt: string; updatedAt: string | null; } ``` From ui/src/lib/agent-role-colors.ts (created in Plan 00): ```typescript export const agentRoleColors: Record; export const agentRoleColorDefault: string; ``` From ui/src/components/ChatMessage.tsx (current): ```typescript interface ChatMessageProps { role: "user" | "assistant" | "system"; content: string; } export function ChatMessage({ role, content }: ChatMessageProps): JSX.Element; ``` Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage - ui/src/components/ChatMessage.tsx - ui/src/components/ChatMarkdownMessage.tsx - ui/src/components/AgentIconPicker.tsx - ui/src/lib/agent-role-colors.ts - ui/src/lib/status-colors.ts - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 72-95 identity bar + streaming cursor) ui/src/components/ChatMessageIdentityBar.tsx, ui/src/components/ChatStreamingCursor.tsx, ui/src/components/ChatMessage.tsx, ui/src/components/ChatMessageIdentityBar.test.tsx **1. Create `ui/src/components/ChatMessageIdentityBar.tsx`:** ```typescript 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" })} )}
); } ``` Per UI spec: icon 16x16 (`h-4 w-4`), name 13px semibold, timestamp 11px muted, streaming dot uses `bg-cyan-400 animate-pulse` from `agentStatusDot.running`. **2. Create `ui/src/components/ChatStreamingCursor.tsx`:** ```typescript export function ChatStreamingCursor() { return (
pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose - grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessageIdentityBar.tsx - grep -q "agentRoleColors" ui/src/components/ChatMessageIdentityBar.tsx - grep -q "ChatStreamingCursor" ui/src/components/ChatStreamingCursor.tsx - grep -q "aria-hidden" ui/src/components/ChatStreamingCursor.tsx - grep -q "animate-cursor-blink" ui/src/components/ChatStreamingCursor.tsx - grep -q "agentName" ui/src/components/ChatMessage.tsx - grep -q "agentRole" ui/src/components/ChatMessage.tsx - grep -q "isStreaming" ui/src/components/ChatMessage.tsx - grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessage.tsx - grep -q "ChatStreamingCursor" ui/src/components/ChatMessage.tsx - grep -q "group" ui/src/components/ChatMessage.tsx - ChatMessageIdentityBar renders icon (h-4 w-4), agent name (13px semibold), timestamp (11px muted), and streaming dot - Agent name and icon use role-specific Tailwind color classes from agentRoleColors - ChatStreamingCursor is inline block with cursor-blink animation and aria-hidden - ChatMessage accepts agentName, agentIcon, agentRole, timestamp, isStreaming props - Assistant messages render identity bar when agentName is present - Assistant messages show streaming cursor when isStreaming is true - Tests pass for ChatMessageIdentityBar
Task 2: ChatAgentSelector component - ui/src/api/agents.ts - ui/src/components/AgentIconPicker.tsx - ui/src/api/chat.ts - ui/src/hooks/useChatConversations.ts - ui/src/components/ChatAgentSelector.test.tsx - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 58-69 agent selector layout) ui/src/components/ChatAgentSelector.tsx, ui/src/components/ChatAgentSelector.test.tsx **1. Create `ui/src/components/ChatAgentSelector.tsx`:** ```typescript import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { agentsApi } from "../api/agents"; import { chatApi } from "../api/chat"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors"; import type { Agent, AgentRole } from "@paperclipai/shared"; import { useState } from "react"; interface ChatAgentSelectorProps { companyId: string; conversationId: string | null; agentId: string | null; onAgentChange: (agentId: string | null) => void; } export function ChatAgentSelector({ companyId, conversationId, agentId, onAgentChange, }: ChatAgentSelectorProps) { const [open, setOpen] = useState(false); const queryClient = useQueryClient(); const { data: agents, isLoading } = useQuery({ queryKey: ["agents", companyId], queryFn: () => agentsApi.list(companyId), enabled: !!companyId, }); const updateMutation = useMutation({ mutationFn: (newAgentId: string) => chatApi.updateConversation(conversationId!, { agentId: newAgentId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); }, }); const activeAgent = agents?.find((a) => a.id === agentId); const handleSelect = (agent: Agent) => { onAgentChange(agent.id); if (conversationId) { updateMutation.mutate(agent.id); } setOpen(false); }; if (isLoading) { return ; } return ( No agents configured {agents?.map((agent) => { const colorClass = agent.role ? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault; return ( handleSelect(agent)} className="flex items-center gap-2" > {agent.name} {agent.role} ); })} ); } ``` Per UI spec: trigger shows icon + name, max-w-120px, truncated; popover 200px wide; items show icon + name + role label; "Select agent" placeholder; "No agents configured" empty state. PATCH conversation on selection (optimistic update via onAgentChange callback). **2. Replace test stubs in `ui/src/components/ChatAgentSelector.test.tsx`:** Update with real tests. Since the component uses React Query and API calls, test the rendering logic: ```typescript // @vitest-environment jsdom import { describe, it, expect } from "vitest"; describe("ChatAgentSelector", () => { it("exports ChatAgentSelector component", async () => { const mod = await import("./ChatAgentSelector"); expect(mod.ChatAgentSelector).toBeDefined(); expect(typeof mod.ChatAgentSelector).toBe("function"); }); it.todo("renders active agent icon and name when agentId is set"); it.todo("renders 'Select agent' placeholder when no agent selected"); it.todo("lists all workspace agents in dropdown"); it.todo("calls onAgentChange with new agentId on selection"); it.todo("shows 'No agents configured' when agent list is empty"); }); ``` Keep most tests as todo since full integration tests require QueryClientProvider mocking. The export test confirms the component loads without errors. pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 - grep -q "ChatAgentSelector" ui/src/components/ChatAgentSelector.tsx - grep -q "aria-label" ui/src/components/ChatAgentSelector.tsx - grep -q "Active agent" ui/src/components/ChatAgentSelector.tsx - grep -q "Select agent" ui/src/components/ChatAgentSelector.tsx - grep -q "No agents configured" ui/src/components/ChatAgentSelector.tsx - grep -q "agentRoleColors" ui/src/components/ChatAgentSelector.tsx - grep -q "onAgentChange" ui/src/components/ChatAgentSelector.tsx - grep -q "updateConversation" ui/src/components/ChatAgentSelector.tsx - grep -q "max-w-\[120px\]" ui/src/components/ChatAgentSelector.tsx - ChatAgentSelector renders active agent with icon + name + ChevronDown - Trigger max-w-120px with truncation - "Select agent" placeholder when no agent selected - Popover lists agents with icon, name, and role label - "No agents configured" empty state - Selection calls onAgentChange and PATCHes conversation - Role-specific colors applied to agent icons - Loading state shows Skeleton - TypeScript compiles clean
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes - `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose` passes - All new components export correctly - Assistant messages show agent name, icon, and timestamp (AGENT-04) - Agent icon colors are role-specific with dark: variants (THEME-03) - Agent selector dropdown allows switching active agent per conversation (CHAT-08) - Streaming cursor blinks during active generation After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`