--- phase: 22-agent-streaming plan: 02 type: execute wave: 1 depends_on: [] files_modified: - ui/src/lib/agent-colors.ts - ui/src/lib/parseMessageIntent.ts - ui/src/components/ChatAgentBadge.tsx - ui/src/components/AgentSelector.tsx - ui/src/components/ChatAgentBadge.test.tsx - ui/src/components/ChatInput.slash-mention.test.tsx - ui/src/lib/parseMessageIntent.test.ts autonomous: true requirements: [AGENT-04, THEME-03, INPUT-05, INPUT-06] must_haves: truths: - "ChatAgentBadge renders the agent name and a colored avatar circle based on agent role" - "Agent avatar colors use --chart-1 through --chart-5 CSS variables, distinguishable across all three themes" - "Slash commands /brainstorm, /ask-pm, /ask-engineer, /task, /search are parsed with correct target role" - "@mention syntax @engineer resolves to target agent name" - "Unknown / prefix passes through as plain text" - "AgentSelector dropdown shows all agents and triggers onSelect callback" artifacts: - path: "ui/src/lib/agent-colors.ts" provides: "agentRoleColorClass function mapping role to Tailwind class" exports: ["agentRoleColorClass"] - path: "ui/src/lib/parseMessageIntent.ts" provides: "parseMessageIntent function for slash commands and @mentions" exports: ["parseMessageIntent", "SLASH_COMMANDS"] - path: "ui/src/components/ChatAgentBadge.tsx" provides: "Agent badge with colored avatar + name" exports: ["ChatAgentBadge"] - path: "ui/src/components/AgentSelector.tsx" provides: "Dropdown to select active agent per conversation" exports: ["AgentSelector"] key_links: - from: "ui/src/components/ChatAgentBadge.tsx" to: "ui/src/lib/agent-colors.ts" via: "import { agentRoleColorClass }" pattern: "agentRoleColorClass" - from: "ui/src/components/AgentSelector.tsx" to: "ui/src/lib/agent-colors.ts" via: "import { agentRoleColorClass }" pattern: "agentRoleColorClass" --- UI foundation components: agent color utility, ChatAgentBadge, AgentSelector dropdown, and slash command / @mention parsing logic with full test coverage. Purpose: Creates the presentational building blocks and pure parsing logic that Plan 03 (Wave 2) wires into the chat panel. All components are self-contained and testable without streaming infrastructure. Output: 4 new files (agent-colors, parseMessageIntent, ChatAgentBadge, AgentSelector) + 3 test files. @$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 From packages/shared/src/types/agent.ts: ```typescript export interface Agent { id: string; companyId: string; name: string; urlKey: string; role: AgentRole; // "ceo" | "pm" | "engineer" | "general" title: string | null; icon: string | null; status: AgentStatus; // ... other fields } ``` From ui/src/api/agents.ts: ```typescript export const agentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/agents`), // ... }; ``` From ui/src/lib/queryKeys.ts: ```typescript agents: { list: (companyId: string) => ["agents", companyId] as const, // ... } ``` Existing agent pages use this pattern for fetching agents: ```typescript const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); ``` From ui/src/lib/agent-icons.ts (existing icon system): ```typescript // Maps icon string names to lucide icon components ``` Shadcn components already installed (per UI-SPEC): Avatar, Badge, Select, Tooltip, Command, Popover Task 1: Agent color utility + parseMessageIntent function + tests ui/src/lib/agent-colors.ts, ui/src/lib/parseMessageIntent.ts, ui/src/lib/parseMessageIntent.test.ts ui/src/lib/agent-icons.ts, ui/src/lib/utils.ts, ui/src/index.css (search for --chart-1 through --chart-5 definitions), .planning/phases/22-agent-streaming/22-UI-SPEC.md (Color section, agent role colors table), .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 4: slash command parsing, Pattern 6: agent colors) - Test: agentRoleColorClass("ceo") returns "bg-[hsl(var(--chart-1))]" - Test: agentRoleColorClass("pm") returns "bg-[hsl(var(--chart-2))]" - Test: agentRoleColorClass("engineer") returns "bg-[hsl(var(--chart-3))]" - Test: agentRoleColorClass("general") returns "bg-[hsl(var(--chart-4))]" - Test: agentRoleColorClass("brainstormer") returns "bg-[hsl(var(--chart-5))]" - Test: agentRoleColorClass("unknown") returns "bg-muted" - Test: parseMessageIntent("/brainstorm Hello") returns { text: "Hello", targetRole: "brainstormer" } - Test: parseMessageIntent("/ask-pm Can you review?") returns { text: "Can you review?", targetRole: "pm" } - Test: parseMessageIntent("/ask-engineer Fix the bug") returns { text: "Fix the bug", targetRole: "engineer" } - Test: parseMessageIntent("/task Create login page") returns { text: "Create login page", targetRole: "engineer" } - Test: parseMessageIntent("/search old messages") returns { text: "old messages", targetRole: "generalist" } - Test: parseMessageIntent("@engineer Hello") returns { text: "Hello", targetName: "engineer" } - Test: parseMessageIntent("@PM-agent Check this") returns { text: "Check this", targetName: "pm-agent" } - Test: parseMessageIntent("/unknown-command Hello") returns { text: "/unknown-command Hello" } (no targetRole) - Test: parseMessageIntent("Just a normal message") returns { text: "Just a normal message" } - Test: parseMessageIntent("/path/to/file.ts") returns { text: "/path/to/file.ts" } (no targetRole — not followed by space) 1. **Create `ui/src/lib/agent-colors.ts`:** ```typescript const ROLE_COLOR_CLASS: Record = { ceo: "bg-[hsl(var(--chart-1))]", pm: "bg-[hsl(var(--chart-2))]", engineer: "bg-[hsl(var(--chart-3))]", general: "bg-[hsl(var(--chart-4))]", generalist: "bg-[hsl(var(--chart-4))]", brainstormer: "bg-[hsl(var(--chart-5))]", }; export function agentRoleColorClass(role: string): string { return ROLE_COLOR_CLASS[role] ?? "bg-muted"; } ``` 2. **Create `ui/src/lib/parseMessageIntent.ts`:** ```typescript export const SLASH_COMMANDS: Record = { "/brainstorm": "brainstormer", "/ask-pm": "pm", "/ask-engineer": "engineer", "/task": "engineer", "/search": "generalist", }; export interface MessageIntent { text: string; targetRole?: string; targetName?: string; } export function parseMessageIntent(content: string): MessageIntent { const trimmed = content.trim(); // Slash command: must match known command followed by whitespace or end-of-string for (const [cmd, role] of Object.entries(SLASH_COMMANDS)) { if (trimmed.toLowerCase().startsWith(cmd)) { const rest = trimmed.slice(cmd.length); // Only match if followed by whitespace or end-of-string (not /path/to/file) if (rest.length === 0 || /^\s/.test(rest)) { return { text: rest.trim() || "", targetRole: role }; } } } // @mention: @word followed by whitespace then content const mentionMatch = trimmed.match(/^@([\w][\w-]*)\s+([\s\S]*)/); if (mentionMatch) { return { text: mentionMatch[2]!.trim(), targetName: mentionMatch[1]!.toLowerCase() }; } return { text: trimmed }; } ``` 3. **Create `ui/src/lib/parseMessageIntent.test.ts`:** Write Vitest tests covering all the behaviors listed above. Use `describe("parseMessageIntent", () => { ... })` and `describe("agentRoleColorClass", () => { ... })` blocks. Import from the respective modules. pnpm --filter @paperclipai/ui test run -- --reporter=verbose parseMessageIntent - test -f ui/src/lib/agent-colors.ts - test -f ui/src/lib/parseMessageIntent.ts - test -f ui/src/lib/parseMessageIntent.test.ts - grep -q "agentRoleColorClass" ui/src/lib/agent-colors.ts returns 0 - grep -q "parseMessageIntent" ui/src/lib/parseMessageIntent.ts returns 0 - grep -q "SLASH_COMMANDS" ui/src/lib/parseMessageIntent.ts returns 0 - grep -q "/brainstorm" ui/src/lib/parseMessageIntent.ts returns 0 - grep -q "@mention" ui/src/lib/parseMessageIntent.ts OR grep -q "targetName" returns 0 - pnpm --filter @paperclipai/ui test run -- parseMessageIntent exits 0 Agent color mapping utility and message intent parsing with slash commands + @mentions both implemented and fully tested Task 2: ChatAgentBadge + AgentSelector components + tests ui/src/components/ChatAgentBadge.tsx, ui/src/components/ChatAgentBadge.test.tsx, ui/src/components/AgentSelector.tsx ui/src/lib/agent-colors.ts (just created in Task 1), ui/src/lib/agent-icons.ts, ui/src/components/ui/select.tsx, ui/src/components/ui/avatar.tsx, ui/src/components/ui/tooltip.tsx, ui/src/components/ui/skeleton.tsx, .planning/phases/22-agent-streaming/22-UI-SPEC.md (ChatAgentBadge and AgentSelector specs) 1. **Create `ui/src/components/ChatAgentBadge.tsx`:** Props: `{ agentId: string | null; agents: Agent[] }` where `Agent` is from `@paperclipai/shared`. Resolve the agent from the `agents` array by matching `agent.id === agentId`. If not found or `agentId` is null, show fallback. Layout per UI-SPEC: - Container: `flex items-center gap-2 mb-1` - Avatar circle: `w-5 h-5 rounded-full flex items-center justify-center` + `agentRoleColorClass(agent.role)` background + `text-white` - If agent has an `icon` value: use the `AgentIcon` component at 12px (check how `agent-icons.ts` maps icon strings to lucide components — read that file). If no `AgentIcon` component exists, render the lucide `Bot` icon at 12px. - If no icon: render first letter of `agent.name` at `text-[10px] font-semibold text-white` - Agent name: `` - Fallback (agent not found): `Bot` icon (12px) + "Agent" text, `bg-muted` background - Avatar element: `aria-hidden="true"` (decorative per accessibility contract) 2. **Create `ui/src/components/ChatAgentBadge.test.tsx`:** Use jsdom + createRoot + act pattern (same as `ChatInput.test.tsx` — read that file for the testing pattern). NOT `@testing-library/react`. Tests: - Renders agent name when agentId matches an agent in the array - Renders "Agent" when agentId is null - Renders "Agent" when agentId does not match any agent in the array - Avatar has aria-hidden="true" - Agent name span has aria-label containing agent name 3. **Create `ui/src/components/AgentSelector.tsx`:** Props: `{ agents: Agent[]; currentAgentId: string | null; onSelect: (agentId: string) => void; isLoading?: boolean }` Implementation per UI-SPEC: - Use shadcn `