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>
  );
}