18 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22-agent-streaming | 02 | execute | 1 |
|
|
true |
|
|
Purpose: Make agent identity visible on every assistant message and allow users to switch agents per conversation. Output: ChatAgentSelector, ChatMessageIdentityBar, ChatStreamingCursor components; extended ChatMessage.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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:
export const agentsApi = {
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
// ...
};
From packages/shared/src/types/chat.ts:
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):
export const agentRoleColors: Record<AgentRole, string>;
export const agentRoleColorDefault: string;
From ui/src/components/ChatMessage.tsx (current):
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`:**
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>
);
}
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:
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"
/>
);
}
Per UI spec: w-2 h-[1em] bg-foreground/70 animate-cursor-blink, aria-hidden="true" (decorative only).
3. Extend ChatMessage props and rendering in ui/src/components/ChatMessage.tsx:
Update the ChatMessageProps interface to:
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
}
Import AgentRole from @paperclipai/shared, ChatMessageIdentityBar, and ChatStreamingCursor.
Update the assistant/system rendering branch to:
return (
<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>
);
The group class enables hover-reveal for edit/retry buttons (Plan 03). User message branch remains unchanged for now (edit action is Plan 03).
4. Replace test stubs in ui/src/components/ChatMessageIdentityBar.test.tsx with real tests:
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
describe("ChatMessageIdentityBar", () => {
it("renders agent name in semibold text", () => {
render(<ChatMessageIdentityBar agentName="PM Agent" />);
expect(screen.getByText("PM Agent")).toBeDefined();
});
it("renders timestamp when provided", () => {
render(<ChatMessageIdentityBar agentName="Test" timestamp="2026-01-01T12:30:00Z" />);
// Should contain formatted time
const el = screen.getByText(/12:30/);
expect(el).toBeDefined();
});
it("applies role-specific color class", () => {
const { container } = render(
<ChatMessageIdentityBar 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", () => {
const { container } = render(
<ChatMessageIdentityBar agentName="Test" isStreaming={true} />
);
const dot = container.querySelector(".animate-pulse");
expect(dot).toBeDefined();
});
});
If @testing-library/react is not installed, use createRoot + container.querySelector pattern from ChatInput.test.tsx.
pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose
<acceptance_criteria>
- 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
</acceptance_criteria>
- 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
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 <Skeleton className="h-7 w-20 rounded" />;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 max-w-[120px] gap-1 px-2 text-xs"
aria-label="Active agent"
>
{activeAgent ? (
<>
<AgentIcon
icon={activeAgent.icon}
className={`h-3.5 w-3.5 ${activeAgent.role ? (agentRoleColors[activeAgent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault}`}
/>
<span className="truncate">{activeAgent.name}</span>
</>
) : (
<span className="text-muted-foreground">Select agent</span>
)}
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty>No agents configured</CommandEmpty>
<CommandGroup>
{agents?.map((agent) => {
const colorClass = agent.role
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
: agentRoleColorDefault;
return (
<CommandItem
key={agent.id}
onSelect={() => handleSelect(agent)}
className="flex items-center gap-2"
>
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
<span className="truncate">{agent.name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
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:
// @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 <acceptance_criteria> - 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 </acceptance_criteria> - 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<success_criteria>
- 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 </success_criteria>