AgentPickerDialog.tsx7.5 KBView on GitHub 'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Plus } from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { agentDisplayName } from '@/modules/agents/utils/agent-name';
import { groupAgents } from '@/modules/agents/utils/agent-groups';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import type { AgentSummary } from '@/modules/agents/types';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';
/** Name given to an agent created straight from this dialog; renamed in its workspace. */
const NEW_AGENT_NAME = 'New agent';
/**
* "Add agent" — the sectioned list the home Agents widget opens.
*
* Agents are grouped BY TYPE — Core, System, Background, In-conversation — with each group's
* name written above its cards, because the flat grid this replaced answered "which of these
* did I make and which shipped with Cedar" only by reading twenty names. See
* `utils/agent-groups.ts` for why System is a derived group rather than a fourth folder.
*
* "Create new agent" sits ABOVE the scroll region rather than inside it as the first card:
* once the list is long enough to scroll, a create affordance that scrolls away with the
* first group is unreachable exactly when the list is most cluttered — which is when you
* most want to stop scrolling and make your own.
*
* The dialog TOGGLES membership rather than closing on pick — pinning three agents is one
* visit, not three. A card already on the home rail says so and removes on click.
*/
export function AgentPickerDialog({
open,
onOpenChange,
pinnedIds,
onTogglePinned,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
pinnedIds: readonly string[];
onTogglePinned: (agentId: string) => void;
}) {
const trpc = useTRPC();
/**
* The workspace is a display ARTIFACT. `/agents/:id` still exists as a deep link, but
* navigating to it from here bounces: the route change remounts LayoutUrlSync, whose
* `lastSyncedAgentUrl` then initialises to the incoming `?agentId` — so the param looks
* already-synced, the store still has no artifact, and the very next tick writes the param
* back to null. Setting the artifact skips the round trip entirely.
*/
const setSelectedArtifact = useCedarStore((state) => state.setSelectedArtifact);
const queryClient = useQueryClient();
const { data: agents, isLoading } = useQuery({
...trpc.agent.list.queryOptions(),
// Nothing to fetch for a dialog nobody has opened.
enabled: open,
});
const { mutate: createAgent, isPending: isCreating } = useMutation(
trpc.agent.create.mutationOptions({
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey=[redacted] });
// Pinned immediately: you opened this dialog to put something on the home rail, so a
// new agent that lands anywhere else has not answered the request.
onTogglePinned(created.agentId);
onOpenChange(false);
setSelectedArtifact({ kind: 'agent', id: created.agentId });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Could not create the agent');
},
}),
);
const list = useMemo(() => (agents as AgentSummary[] | undefined) ?? [], [agents]);
const sections = useMemo(() => groupAgents(list), [list]);
const pinned = useMemo(() => new Set(pinnedIds), [pinnedIds]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="wide">
<DialogHeader>
<DialogTitle>Add an agent to your home screen</DialogTitle>
</DialogHeader>
<button
type="button"
onClick={() => createAgent({ name: NEW_AGENT_NAME })}
disabled={isCreating}
className="flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl border border-dashed border-border px-3 py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50"
>
<Plus aria-hidden className="h-4 w-4 shrink-0" />
{isCreating ? 'Creating…' : 'Create new agent'}
</button>
<div className="-mr-2 max-h-[60vh] overflow-y-auto pr-2">
{isLoading ? (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-[5.5rem] w-full rounded-xl" />
))}
</div>
) : sections.length === 0 ? (
<p className="text-muted-foreground text-sm">
You have no agents yet — create one above.
</p>
) : (
// The gap BETWEEN sections (5) is deliberately larger than the gap between a
// heading and its own cards (2): a heading belongs to what is under it, and at
// one even spacing the whole thing reads as an undivided run again.
<div className="flex flex-col gap-5">
{sections.map((section) => (
<section key=[redacted] data-testid={`agent-picker-group-${section.group}`}>
{/* Sticky so the heading is still legible once you have scrolled past its
first row — the whole point of the sections is knowing where you are. */}
<h3 className="sticky top-0 z-10 -mx-1 mb-2 bg-background px-1 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{section.label}
<span className="ml-1.5 text-muted-foreground/60">{section.agents.length}</span>
</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{section.agents.map((agent) => (
<AgentPickerCard
key=[redacted]
agent={agent}
pinned={pinned.has(agent.agentId)}
onToggle={() => onTogglePinned(agent.agentId)}
/>
))}
</div>
</section>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function AgentPickerCard({
agent,
pinned,
onToggle,
}: {
agent: AgentSummary;
pinned: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
data-testid="agent-picker-card"
aria-pressed={pinned}
onClick={onToggle}
className={cn(
'flex min-h-[5.5rem] cursor-pointer flex-col rounded-xl border p-3 text-left transition-colors',
pinned
? 'border-primary/50 bg-accent'
: 'border-border bg-raised hover:border-primary/30 hover:bg-accent',
)}
>
<div className="flex min-w-0 items-center gap-2">
<AgentAvatar agentId={agent.agentId} avatar={agent.avatar} className="h-5 w-5 shrink-0" />
<span className="truncate text-sm font-medium text-foreground">
{agentDisplayName(agent.name)}
</span>
{pinned && <Check aria-hidden className="ml-auto h-4 w-4 shrink-0 text-primary" />}
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{agent.description ?? ''}</p>
</button>
);
}