---
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"
---

<objective>
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.
</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
@.planning/phases/22-agent-streaming/22-00-SUMMARY.md

<interfaces>
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<string, AgentRole | null> = {
  "/brainstorm": "general",
  "/ask-pm": "pm",
  "/ask-engineer": "engineer",
  "/task": "pm",
  "/search": null,  // Phase 22 stub
};
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Slash command routing utility and ChatSlashCommandPopover</name>
  <read_first>
    - .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
  </read_first>
  <files>
    ui/src/lib/slash-commands.ts,
    ui/src/components/ChatSlashCommandPopover.tsx,
    ui/src/components/ChatSlashCommandPopover.test.tsx
  </files>
  <action>
**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 (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>{children}</PopoverTrigger>
      <PopoverContent
        className="w-[260px] p-0"
        align="start"
        side="top"
        onOpenAutoFocus={(e) => e.preventDefault()}
      >
        <Command>
          <CommandList>
            <CommandEmpty>No matching commands</CommandEmpty>
            <CommandGroup>
              {filtered.map((cmd) => (
                <CommandItem
                  key={cmd.command}
                  disabled={cmd.disabled}
                  onSelect={() => {
                    if (!cmd.disabled) {
                      onSelect(cmd.command);
                      onOpenChange(false);
                    }
                  }}
                  className={cn("flex flex-col items-start", cmd.disabled && "opacity-50")}
                >
                  <span className="text-sm font-medium">{cmd.command}</span>
                  <span className="text-[13px] text-muted-foreground">
                    {cmd.description}
                    {cmd.disabled && " (Coming soon)"}
                  </span>
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}
```

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();
  });
});
```
  </action>
  <verify>
    <automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    - 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
  </done>
</task>

<task type="auto">
  <name>Task 2: ChatMentionPopover component</name>
  <read_first>
    - 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
  </read_first>
  <files>
    ui/src/components/ChatMentionPopover.tsx,
    ui/src/components/ChatMentionPopover.test.tsx
  </files>
  <action>
**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 (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>{children}</PopoverTrigger>
      <PopoverContent
        className="w-[200px] p-1"
        align="start"
        side="top"
        onOpenAutoFocus={(e) => e.preventDefault()}
      >
        {isLoading ? (
          <div className="space-y-1 p-1">
            <Skeleton className="h-7 w-full" />
            <Skeleton className="h-7 w-full" />
            <Skeleton className="h-7 w-full" />
          </div>
        ) : filtered.length === 0 ? (
          <p className="text-sm text-muted-foreground text-center py-2">No agents found</p>
        ) : (
          <div className="max-h-[180px] overflow-auto">
            {filtered.slice(0, 5).map((agent) => {
              const colorClass = agent.role
                ? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
                : agentRoleColorDefault;
              return (
                <button
                  key={agent.id}
                  className="flex items-center gap-2 w-full rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
                  onClick={() => {
                    onSelect(agent.name);
                    onOpenChange(false);
                  }}
                >
                  <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>
                </button>
              );
            })}
          </div>
        )}
      </PopoverContent>
    </Popover>
  );
}
```

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");
});
```
  </action>
  <verify>
    <automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    - 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
  </done>
</task>

</tasks>

<verification>
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm --filter @paperclipai/ui vitest run src/components/ChatSlashCommandPopover.test.tsx --reporter=verbose` passes
</verification>

<success_criteria>
- 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
</success_criteria>

<output>
After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`
</output>
