446 lines
16 KiB
Markdown
446 lines
16 KiB
Markdown
---
|
|
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>
|