feat(22-04): slash command routing utility and ChatSlashCommandPopover
- Create ui/src/lib/slash-commands.ts with SLASH_COMMANDS (5 commands) and resolveAgentFromContent - Create ChatSlashCommandPopover (260px, opens upward, /search greyed with Coming soon) - Add test coverage for routing logic (slash commands, @mentions, fallback)
This commit is contained in:
parent
47430d0e93
commit
a1fc8b9dab
3 changed files with 153 additions and 6 deletions
|
|
@ -1,9 +1,42 @@
|
||||||
import { describe, it } from "vitest";
|
// @vitest-environment jsdom
|
||||||
|
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", () => {
|
describe("ChatSlashCommandPopover", () => {
|
||||||
it.todo("renders 5 slash command items when open");
|
it("exports ChatSlashCommandPopover component", async () => {
|
||||||
it.todo("filters commands by typed query");
|
const mod = await import("./ChatSlashCommandPopover");
|
||||||
it.todo("calls onSelect with command string on item click");
|
expect(mod.ChatSlashCommandPopover).toBeDefined();
|
||||||
it.todo("closes on Escape key");
|
});
|
||||||
it.todo("shows /search as greyed out with 'Coming soon' suffix");
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
69
ui/src/components/ChatSlashCommandPopover.tsx
Normal file
69
ui/src/components/ChatSlashCommandPopover.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { SLASH_COMMANDS } 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
ui/src/lib/slash-commands.ts
Normal file
45
ui/src/lib/slash-commands.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue