--- phase: 22-agent-streaming plan: "04" type: execute wave: 2 depends_on: ["22-00"] files_modified: - ui/src/components/ChatSlashCommandPopover.tsx - ui/src/components/ChatMentionPopover.tsx - ui/src/lib/slash-commands.ts - ui/src/components/ChatSlashCommandPopover.test.tsx - ui/src/components/ChatMentionPopover.test.tsx autonomous: true requirements: - INPUT-05 - INPUT-06 must_haves: truths: - "Typing / as first character in ChatInput opens the slash command popover" - "Typing @ in ChatInput opens the agent mention popover" - "Selecting a slash command inserts the command prefix into the textarea" - "Selecting an @mention inserts @agentName into the textarea" - "/search command is shown but greyed out with 'Coming soon' suffix" artifacts: - path: "ui/src/components/ChatSlashCommandPopover.tsx" provides: "Slash command menu UI" exports: ["ChatSlashCommandPopover"] - path: "ui/src/components/ChatMentionPopover.tsx" provides: "Agent @mention autocomplete UI" exports: ["ChatMentionPopover"] - path: "ui/src/lib/slash-commands.ts" provides: "Slash command definitions and routing table" exports: ["SLASH_COMMANDS", "resolveAgentFromContent"] key_links: - from: "ui/src/lib/slash-commands.ts" to: "@paperclipai/shared constants" via: "AgentRole type for routing" pattern: "AgentRole" - from: "ui/src/components/ChatSlashCommandPopover.tsx" to: "ui/src/lib/slash-commands.ts" via: "import SLASH_COMMANDS" pattern: "SLASH_COMMANDS" --- Slash command popover (INPUT-05) and @mention popover (INPUT-06). These are standalone components that will be wired into ChatInput in Plan 05. Also creates the slash command routing utility that ChatPanel will use to resolve agent from message content. Purpose: Enable slash commands and @mentions for routing messages to specific agents. Output: ChatSlashCommandPopover, ChatMentionPopover components; slash-commands utility with routing logic. @$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 packages/shared/src/constants.ts: ```typescript export const AGENT_ROLES = ["pm", "engineer", "ceo", "general", "designer", "qa", "researcher", "devops", "cto", "cmo", "cfo"] as const; export type AgentRole = (typeof AGENT_ROLES)[number]; ``` From ui/src/components/AgentIconPicker.tsx: ```typescript export function AgentIcon({ icon, className }: { icon?: string | null; className?: string }): JSX.Element; ``` From packages/shared types: ```typescript interface Agent { id: string; name: string; role: AgentRole; icon: string | null; /* ... */ } ``` From 22-RESEARCH.md Pattern 5: ```typescript const SLASH_COMMAND_ROUTES: Record = { "/brainstorm": "general", "/ask-pm": "pm", "/ask-engineer": "engineer", "/task": "pm", "/search": null, // Phase 22 stub }; ``` Task 1: Slash command routing utility and ChatSlashCommandPopover - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 122-140 slash commands) - .planning/phases/22-agent-streaming/22-RESEARCH.md (Pattern 5 slash command routing) - ui/src/components/ChatInput.tsx ui/src/lib/slash-commands.ts, ui/src/components/ChatSlashCommandPopover.tsx, ui/src/components/ChatSlashCommandPopover.test.tsx **1. Create `ui/src/lib/slash-commands.ts`:** ```typescript import type { AgentRole } from "@paperclipai/shared"; export interface SlashCommand { command: string; description: string; routesTo: AgentRole | null; disabled?: boolean; } export const SLASH_COMMANDS: SlashCommand[] = [ { command: "/brainstorm", description: "Route to Brainstormer", routesTo: "general" }, { command: "/ask-pm", description: "Route to PM", routesTo: "pm" }, { command: "/ask-engineer", description: "Route to Engineer", routesTo: "engineer" }, { command: "/task", description: "Create a task", routesTo: "pm" }, { command: "/search", description: "Search conversations", routesTo: null, disabled: true }, ]; /** * Resolves which agent should receive a message based on slash command prefix or @mention. * Returns the agent ID to route to, or the active agent ID if no routing override found. */ export function resolveAgentFromContent( content: string, agents: Array<{ id: string; name: string; role: string }>, activeAgentId: string | null, ): string | null { // Slash command takes highest priority const slashMatch = content.match(/^(\/\S+)/); if (slashMatch) { const cmd = slashMatch[1]; const slashCmd = SLASH_COMMANDS.find((c) => c.command === cmd); if (slashCmd?.routesTo) { const agent = agents.find((a) => a.role === slashCmd.routesTo); if (agent) return agent.id; } } // @mention takes second priority const mentionMatch = content.match(/@(\S+)/); if (mentionMatch) { const name = mentionMatch[1]!.toLowerCase(); const agent = agents.find((a) => a.name.toLowerCase().startsWith(name)); if (agent) return agent.id; } return activeAgentId; } ``` **2. Create `ui/src/components/ChatSlashCommandPopover.tsx`:** ```typescript import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; import { SLASH_COMMANDS, type SlashCommand } from "../lib/slash-commands"; import { cn } from "../lib/utils"; interface ChatSlashCommandPopoverProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (command: string) => void; query: string; children: React.ReactNode; } export function ChatSlashCommandPopover({ open, onOpenChange, onSelect, query, children, }: ChatSlashCommandPopoverProps) { const filtered = SLASH_COMMANDS.filter((cmd) => cmd.command.toLowerCase().includes(query.toLowerCase()), ); return ( {children} e.preventDefault()} > No matching commands {filtered.map((cmd) => ( { if (!cmd.disabled) { onSelect(cmd.command); onOpenChange(false); } }} className={cn("flex flex-col items-start", cmd.disabled && "opacity-50")} > {cmd.command} {cmd.description} {cmd.disabled && " (Coming soon)"} ))} ); } ``` Per UI spec: 260px wide, opens upward (`side="top"`), items show command + description, `/search` greyed out with "Coming soon" suffix. `onOpenAutoFocus` prevented so textarea keeps focus. **3. Replace test stubs in `ui/src/components/ChatSlashCommandPopover.test.tsx`:** ```typescript import { describe, it, expect } from "vitest"; import { SLASH_COMMANDS, resolveAgentFromContent } from "../lib/slash-commands"; describe("slash-commands", () => { it("defines 5 slash commands", () => { expect(SLASH_COMMANDS).toHaveLength(5); }); it("/search is disabled", () => { const search = SLASH_COMMANDS.find((c) => c.command === "/search"); expect(search?.disabled).toBe(true); }); it("resolveAgentFromContent routes /ask-pm to pm agent", () => { const agents = [ { id: "a1", name: "PM", role: "pm" }, { id: "a2", name: "Eng", role: "engineer" }, ]; expect(resolveAgentFromContent("/ask-pm hello", agents, null)).toBe("a1"); }); it("resolveAgentFromContent routes @mention to matching agent", () => { const agents = [ { id: "a1", name: "PM Agent", role: "pm" }, { id: "a2", name: "Engineer", role: "engineer" }, ]; expect(resolveAgentFromContent("Hey @engineer help", agents, null)).toBe("a2"); }); it("resolveAgentFromContent returns activeAgentId when no match", () => { const agents = [{ id: "a1", name: "PM", role: "pm" }]; expect(resolveAgentFromContent("just a message", agents, "fallback-id")).toBe("fallback-id"); }); }); describe("ChatSlashCommandPopover", () => { it("exports ChatSlashCommandPopover component", async () => { const mod = await import("./ChatSlashCommandPopover"); expect(mod.ChatSlashCommandPopover).toBeDefined(); }); }); ``` pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose - grep -q "SLASH_COMMANDS" ui/src/lib/slash-commands.ts - grep -q "resolveAgentFromContent" ui/src/lib/slash-commands.ts - grep -q "/brainstorm" ui/src/lib/slash-commands.ts - grep -q "/search" ui/src/lib/slash-commands.ts - grep -q "Coming soon" ui/src/components/ChatSlashCommandPopover.tsx - grep -q "w-\[260px\]" ui/src/components/ChatSlashCommandPopover.tsx - grep -q "side=\"top\"" ui/src/components/ChatSlashCommandPopover.tsx - SLASH_COMMANDS array with 5 commands, /search disabled - resolveAgentFromContent resolves slash commands first, then @mentions, then falls back to active agent - ChatSlashCommandPopover renders 260px popover opening upward with command list - Disabled commands shown greyed with "Coming soon" - Tests pass for routing logic Task 2: ChatMentionPopover component - ui/src/components/AgentIconPicker.tsx - ui/src/lib/agent-role-colors.ts - .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 139-148 mention popover) - ui/src/components/ChatMentionPopover.test.tsx ui/src/components/ChatMentionPopover.tsx, ui/src/components/ChatMentionPopover.test.tsx **1. Create `ui/src/components/ChatMentionPopover.tsx`:** ```typescript import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { AgentIcon } from "./AgentIconPicker"; import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors"; import type { Agent, AgentRole } from "@paperclipai/shared"; import { Skeleton } from "@/components/ui/skeleton"; interface ChatMentionPopoverProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (agentName: string) => void; query: string; agents: Agent[]; isLoading?: boolean; children: React.ReactNode; } export function ChatMentionPopover({ open, onOpenChange, onSelect, query, agents, isLoading, children, }: ChatMentionPopoverProps) { const filtered = agents.filter((a) => a.name.toLowerCase().includes(query.toLowerCase()), ); return ( {children} e.preventDefault()} > {isLoading ? (
) : filtered.length === 0 ? (

No agents found

) : (
{filtered.slice(0, 5).map((agent) => { const colorClass = agent.role ? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault; return ( ); })}
)}
); } ``` Per UI spec: 200px wide, opens upward, each row has icon (14x14, `h-3.5 w-3.5`) + name + role label in muted text, max 5 visible, "No agents found" empty state, 3 skeleton rows loading state. `onOpenAutoFocus` prevented to keep textarea focus. **2. Replace test stubs in `ui/src/components/ChatMentionPopover.test.tsx`:** ```typescript import { describe, it, expect } from "vitest"; describe("ChatMentionPopover", () => { it("exports ChatMentionPopover component", async () => { const mod = await import("./ChatMentionPopover"); expect(mod.ChatMentionPopover).toBeDefined(); }); it.todo("renders agent list filtered by query string"); it.todo("shows agent icon, name, and role for each item"); it.todo("calls onSelect with agent name on item click"); it.todo("shows 'No agents found' when filter matches nothing"); }); ```
pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 - grep -q "ChatMentionPopover" ui/src/components/ChatMentionPopover.tsx - grep -q "No agents found" ui/src/components/ChatMentionPopover.tsx - grep -q "w-\[200px\]" ui/src/components/ChatMentionPopover.tsx - grep -q "side=\"top\"" ui/src/components/ChatMentionPopover.tsx - grep -q "agentRoleColors" ui/src/components/ChatMentionPopover.tsx - grep -q "onSelect" ui/src/components/ChatMentionPopover.tsx - grep -q "Skeleton" ui/src/components/ChatMentionPopover.tsx - ChatMentionPopover renders 200px upward popover with agent list - Agents filtered by query (case-insensitive) - Each row shows icon (with role color), name (truncated), and role label (muted) - Max 5 agents visible before scroll - "No agents found" empty state - 3 skeleton rows loading state - Selecting an agent calls onSelect(agentName) and closes popover - TypeScript compiles clean
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes - `pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose` passes - 5 slash commands available with /search greyed out (INPUT-05) - @mention popover shows agents filtered by query (INPUT-06) - resolveAgentFromContent routes messages based on /command or @mention - Both popovers open upward, anchored to textarea, with proper widths After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`