nexus/.planning/phases/22-agent-streaming/22-02-PLAN.md
Mikkel Georgsen e8c70d6c8d [nexus] fix(22): revise plans based on checker feedback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:45:00 +02:00

345 lines
16 KiB
Markdown

---
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"]
- path: "ui/src/components/ChatInput.slash-mention.test.tsx"
provides: "Integration tests for slash command and @mention parsing in ChatInput context"
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Agent type from shared package -->
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<Agent[]>(`/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
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Agent color utility + parseMessageIntent function + tests (including slash/mention integration stubs)</name>
<files>
ui/src/lib/agent-colors.ts,
ui/src/lib/parseMessageIntent.ts,
ui/src/lib/parseMessageIntent.test.ts,
ui/src/components/ChatInput.slash-mention.test.tsx
</files>
<read_first>
ui/src/lib/agent-icons.ts,
ui/src/lib/utils.ts,
ui/src/index.css (search for --chart-1 through --chart-5 definitions),
ui/src/components/ChatInput.tsx,
ui/src/components/ChatInput.test.tsx,
.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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
1. **Create `ui/src/lib/agent-colors.ts`:**
```typescript
const ROLE_COLOR_CLASS: Record<string, string> = {
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<string, string> = {
"/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.
4. **Create `ui/src/components/ChatInput.slash-mention.test.tsx`:** Create test stubs for INPUT-05 (slash command parsing in ChatInput context) and INPUT-06 (@mention parsing in ChatInput context). These test the integration between ChatInput and parseMessageIntent:
- Use the same jsdom + createRoot + act pattern as `ChatInput.test.tsx`.
- Test stub (INPUT-05): "slash command prefix filters SLASH_COMMANDS and shows popover" -- import `SLASH_COMMANDS`, verify the exported constant has entries for /brainstorm, /ask-pm, /ask-engineer, /task, /search. (Full popover rendering tests will be added in Plan 03 when the popover is wired into ChatInput.)
- Test stub (INPUT-06): "@mention prefix resolves agent name" -- import `parseMessageIntent`, verify `parseMessageIntent("@test-agent hello").targetName` equals "test-agent". (Full popover rendering tests added in Plan 03.)
- Mark any tests that depend on Plan 03 UI changes with `it.todo(...)` so they are tracked but do not block.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui test run -- --reporter=verbose parseMessageIntent ChatInput.slash</automated>
</verify>
<acceptance_criteria>
- test -f ui/src/lib/agent-colors.ts
- test -f ui/src/lib/parseMessageIntent.ts
- test -f ui/src/lib/parseMessageIntent.test.ts
- test -f ui/src/components/ChatInput.slash-mention.test.tsx
- 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
- grep -q "INPUT-05" ui/src/components/ChatInput.slash-mention.test.tsx returns 0
- grep -q "INPUT-06" ui/src/components/ChatInput.slash-mention.test.tsx returns 0
- pnpm --filter @paperclipai/ui test run -- parseMessageIntent exits 0
- pnpm --filter @paperclipai/ui test run -- ChatInput.slash exits 0
</acceptance_criteria>
<done>Agent color mapping utility and message intent parsing with slash commands + @mentions both implemented and fully tested. ChatInput slash/mention integration test stubs created for INPUT-05 and INPUT-06.</done>
</task>
<task type="auto">
<name>Task 2: ChatAgentBadge + AgentSelector components + tests</name>
<files>
ui/src/components/ChatAgentBadge.tsx,
ui/src/components/ChatAgentBadge.test.tsx,
ui/src/components/AgentSelector.tsx
</files>
<read_first>
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)
</read_first>
<action>
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: `<span className="text-[13px] text-muted-foreground truncate max-w-[120px]" aria-label={`Agent: ${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 `<Select>` component
- Trigger: `h-8 px-2 py-1`, shows current agent mini avatar (16px circle, same color mapping) + agent name (13px / truncate)
- Wrap trigger in `<Tooltip>` with content "Active agent for this conversation"
- Trigger has `aria-label="Active agent: ${currentAgent?.name ?? 'None'}"`
- Dropdown items: each agent as `<SelectItem value={agent.id}>` showing:
- 16px colored circle (same `agentRoleColorClass`) + agent name (14px / regular)
- If `agents` is empty: single disabled item "No agents configured"
- If `isLoading`: render `<Skeleton className="h-8 w-28" />`
- On value change: call `onSelect(value)`
No test file needed for AgentSelector (it's a thin UI wrapper over shadcn Select with no logic -- the color mapping is tested via agent-colors, and the integration will be verified in Plan 03's checkpoint).
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui test run -- --reporter=verbose ChatAgentBadge</automated>
</verify>
<acceptance_criteria>
- test -f ui/src/components/ChatAgentBadge.tsx
- test -f ui/src/components/ChatAgentBadge.test.tsx
- test -f ui/src/components/AgentSelector.tsx
- grep -q "agentRoleColorClass" ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q 'aria-hidden="true"' ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q 'aria-label' ui/src/components/ChatAgentBadge.tsx returns 0
- grep -q "AgentSelector" ui/src/components/AgentSelector.tsx returns 0
- grep -q "Active agent for this conversation" ui/src/components/AgentSelector.tsx returns 0
- grep -q "No agents configured" ui/src/components/AgentSelector.tsx returns 0
- pnpm --filter @paperclipai/ui test run -- ChatAgentBadge exits 0
- pnpm --filter @paperclipai/ui build exits 0 (TypeScript compiles)
</acceptance_criteria>
<done>ChatAgentBadge renders agent identity with role-based colors, AgentSelector provides dropdown to switch agents, badge tests pass, UI builds</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui test run` -- all UI tests pass
- `pnpm --filter @paperclipai/ui build` -- TypeScript compiles
- Agent color utility tested for all 5 roles + fallback
- parseMessageIntent tested for all 5 slash commands + @mention + plain text + edge cases
- ChatAgentBadge tested for render + fallback + accessibility
- ChatInput.slash-mention.test.tsx exists with INPUT-05 and INPUT-06 stubs
</verification>
<success_criteria>
1. agentRoleColorClass maps all 5 agent roles to chart-1 through chart-5 CSS variables
2. parseMessageIntent correctly parses all 5 slash commands and @mention syntax
3. ChatAgentBadge renders agent name + colored avatar, with fallback for unknown agents
4. AgentSelector provides a dropdown with tooltip and empty state
5. ChatInput.slash-mention.test.tsx has test stubs for INPUT-05 and INPUT-06
6. All UI tests pass and build succeeds
</success_criteria>
<output>
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`
</output>