nexus/.planning/phases/22-agent-streaming/22-04-PLAN.md

16 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
22-agent-streaming 04 execute 2
22-00
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
true
INPUT-05
INPUT-06
truths artifacts key_links
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
path provides exports
ui/src/components/ChatSlashCommandPopover.tsx Slash command menu UI
ChatSlashCommandPopover
path provides exports
ui/src/components/ChatMentionPopover.tsx Agent @mention autocomplete UI
ChatMentionPopover
path provides exports
ui/src/lib/slash-commands.ts Slash command definitions and routing table
SLASH_COMMANDS
resolveAgentFromContent
from to via pattern
ui/src/lib/slash-commands.ts @paperclipai/shared constants AgentRole type for routing AgentRole
from to via pattern
ui/src/components/ChatSlashCommandPopover.tsx ui/src/lib/slash-commands.ts import SLASH_COMMANDS 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.

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

export function AgentIcon({ icon, className }: { icon?: string | null; className?: string }): JSX.Element;

From packages/shared types:

interface Agent { id: string; name: string; role: AgentRole; icon: string | null; /* ... */ }

From 22-RESEARCH.md Pattern 5:

const SLASH_COMMAND_ROUTES: Record<string, AgentRole | null> = {
  "/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`:**
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:

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:

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`:**
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:

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

<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>
After completion, create `.planning/phases/22-agent-streaming/22-04-SUMMARY.md`