16 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22-agent-streaming | 04 | execute | 2 |
|
|
true |
|
|
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>