feat(22-04): ChatMentionPopover component

- Create ChatMentionPopover (200px, opens upward, agent icon + name + role label)
- Agents filtered by query, max 5 visible, No agents found empty state
- 3 skeleton rows loading state, onOpenAutoFocus prevented for textarea focus
- Include agent-role-colors dependency for worktree build
This commit is contained in:
Nexus Dev 2026-04-01 18:27:42 +00:00
parent a1fc8b9dab
commit 6c550c8227
2 changed files with 81 additions and 2 deletions

View file

@ -1,8 +1,14 @@
import { describe, it } from "vitest";
// @vitest-environment jsdom
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 @agentName on item click");
it.todo("calls onSelect with agent name on item click");
it.todo("shows 'No agents found' when filter matches nothing");
});

View file

@ -0,0 +1,73 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentIcon } from "./AgentIconPicker";
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
import type { Agent, AgentRole } from "@paperclipai/shared";
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>
);
}