AgentsGrid.tsx13.2 KBView on GitHub
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';

import { agentDisplayName } from '@/modules/agents/utils/agent-name';
import { groupAgents } from '@/modules/agents/utils/agent-groups';
import { agentAopIdFromDocumentPath } from '@/modules/agents/utils/agent-paths';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import type { AgentSummary } from '@/modules/agents/types';
import { brainDocumentPath } from '@/modules/brain/brain-routes';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';

/** Name given to an agent created from this grid; renamed in its workspace. */
const NEW_AGENT_NAME = 'New agent';

/**
 * Every agent you have, as one titled grid, sectioned by the folder it is filed in.
 *
 * This is a BLOCK, not a screen — it renders inline on the Brain hero, under the composer and
 * the two destination cards. Agents are the Brain's main body rather than a card that leads to
 * a list of them: the list is short enough to read where you land, and a card whose only job is
 * to reveal a grid is a click charged for nothing.
 *
 * The sections come from `groupAgents` — Core, System, Background, In-conversation — the same
 * arrangement the home rail's "Add agent" dialog uses, so the two never disagree about where an
 * agent lives. System is a DERIVED group rather than a fourth folder; see `utils/agent-groups.ts`.
 *
 * A card opens the agent's WORKSPACE, which is a display artifact rather than a screen of its
 * own: `/agent?agentId=<id>` is the address for it (LayoutUrlSync hydrates the param into the
 * artifact), and it is the same address `/agents/:agentId` redirects to.
 *
 * CREATING IS THE FIRST CARD, not a button in the title row. "Make another one" is the same
 * KIND of thing as the cards beside it — you are picking an agent to work on, and one of the
 * choices is a new one — so it belongs in the grid, in the first slot, wearing a dashed border
 * to say it is the empty one. A button off in the corner made the title row a control strip and
 * put the one creative act on this screen furthest from everything it creates.
 *
 * It is also, for free, the empty state: an account with no agents renders that card alone,
 * which says what to do without a separate paragraph saying it.
 *
 * No scroller of its own. The hero row is the page's single scrollbar, so a long agent list
 * simply makes the page longer — a grid that scrolled inside a fixed box would slide out of
 * step with the recents rail beside it, and hide its own last row behind a second bar.
 */
export function AgentsGrid({
  targetUserId,
  aopFilterIds,
  className,
}: {
  /**
   * An org admin/owner browsing a teammate's agents — passed down from `BrainHeroBelowChat`,
   * which mounts the picker (`AdministeredUserBar`) this grid has none of itself. `undefined`
   * is every ordinary visit: your own agents, the create card live, opening one going straight
   * to `/agent?agentId=`.
   */
  targetUserId?: string;
  /**
   * Restrict the grid to agents filed under one of these AOP ids — the AOP-name picker's
   * selection, from `BrainHeroBelowChat`. A user AOP's own `id` and its `orgAopId` both
   * match (an org-scoped agent's document lives under the ORG id, not the user one), so
   * the caller passes both when it knows them. `undefined`/empty: every agent, unfiltered.
   */
  aopFilterIds?: string[];
  className?: string;
}) {
  const trpc = useTRPC();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const [creating, setCreating] = useState(false);

  const { data: agents, isLoading } = useQuery(trpc.agent.list.queryOptions({ targetUserId }));

  /**
   * `/agent?agentId=` only ever resolves in the CALLER's own AOP set (`DisplayArtifactPanel`
   * has no `targetUserId` slot — a separate gap from this one, left for its own change). A
   * teammate's card instead opens through `brainDocumentPath`, which `AgentDocumentView`
   * already resolves admin-aware end to end — the exact path a Brain → Playbooks file click
   * uses today.
   */
  const openAgent = (agent: { agentId: string; documentId?: string | null }) =>
    void navigate(
      targetUserId && agent.documentId
        ? brainDocumentPath(agent.documentId, targetUserId)
        : `/agent?agentId=${encodeURIComponent(agent.agentId)}`,
    );

  const { mutate: createAgent } = useMutation(
    trpc.agent.create.mutationOptions({
      onMutate: () => setCreating(true),
      onSettled: () => setCreating(false),
      onSuccess: (created) => {
        void queryClient.invalidateQueries({ queryKey=[redacted] });
        // Straight into the new agent's workspace — creating one is the first half of
        // configuring it, and a fresh "New agent" card added to a grid of twenty says nothing
        // about what to do next.
        openAgent(created);
      },
      onError: (error) => {
        toast.error(error instanceof Error ? error.message : 'Could not create the agent');
      },
    }),
  );

  // NOT cast. `agent.list` is annotated `Promise<AgentSummary[]>` on the router, so tRPC
  // already infers the element type — an `as` here would override the one thing that would
  // catch the shape changing under this grid. See CLAUDE.md.
  const list = useMemo(() => agents ?? [], [agents]);
  const filtered = useMemo(() => {
    if (!aopFilterIds || aopFilterIds.length === 0) return list;
    return list.filter((agent) => {
      const aopId = agentAopIdFromDocumentPath(agent.documentPath);
      return aopId !== null && aopFilterIds.includes(aopId);
    });
  }, [list, aopFilterIds]);
  const sections = useMemo(() => groupAgents(filtered), [filtered]);

  return (
    <section className={cn('flex flex-col', className)}>
      <h2 className="mb-4 text-2xl font-semibold tracking-tight text-foreground">Agents</h2>

      {isLoading ? (
        <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
          {[0, 1, 2, 3, 4, 5].map((i) => (
            <Skeleton key={i} className="h-[6.5rem] w-full rounded-xl" />
          ))}
        </div>
      ) : sections.length === 0 && list.length > 0 ? (
        // The AOP filter matched nothing — a real fact about THIS selection, not about
        // whether any agent exists, so neither empty-state below (which are both about
        // having zero agents outright) applies here.
        <p className="text-sm text-muted-foreground">
          No agents filed under this conversation type.
        </p>
      ) : sections.length === 0 ? (
        // No agents at all. Self: the create card IS the empty state, no heading needed — see
        // below. Viewing a teammate: creating on their behalf is not this grid's job (`agent.create`
        // has no admin path — see AgentActionMenu's `duplicate` for the identical reasoning), so
        // there is nothing to draw but the plain fact.
        targetUserId ? (
          <p className="text-sm text-muted-foreground">This teammate has no agents yet.</p>
        ) : (
          <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
            <NewAgentCard
              creating={creating}
              onCreate={() => createAgent({ name: NEW_AGENT_NAME })}
            />
          </div>
        )
      ) : (
        // The gap BETWEEN sections is deliberately larger than the gap between a heading and
        // its own cards: a heading belongs to what is under it, and at one even spacing the
        // whole grid reads as an undivided run again.
        <div className="flex flex-col gap-7">
          {sections.map((section, index) => (
            <div key=[redacted] data-testid={`agents-group-${section.group}`}>
              <h3 className="mb-2.5 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-3 sm:grid-cols-2 lg:grid-cols-3">
                {/* First slot of the FIRST section — the top-left cell of the whole grid, which
                    is where the eye starts. A new agent is filed in `core`, which is the first
                    section, so the card also sits in the folder it will land in. Withheld while
                    viewing a teammate (see the empty-state branch above) and while an AOP filter
                    is active — a new agent lands wherever `firstAopIdForUser` picks, which is
                    not necessarily the AOP the grid is currently narrowed to. */}
                {index === 0 && !targetUserId && !aopFilterIds?.length && (
                  <NewAgentCard
                    creating={creating}
                    onCreate={() => createAgent({ name: NEW_AGENT_NAME })}
                  />
                )}
                {section.agents.map((agent) => (
                  <AgentCard key=[redacted] agent={agent} onOpen={() => openAgent(agent)} />
                ))}
              </div>
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

/**
 * The create card — an agent card with nothing in it yet.
 *
 * It is the SAME three-part layout as its siblings, part for part: picture, title on the
 * baseline beside it, one line of description under. Only the picture differs — a plus where
 * the coloured body and face go, because the one thing this card does not have is an agent.
 * Built any other way (a centred plus over a centred label) it read as a button that had
 * wandered into a grid of cards, and the first row stepped.
 *
 * The dashed border sits at the hover colour PERMANENTLY rather than lifting to it on hover.
 * A dash pattern at a fainter weight than the solid borders around it reads as a card that is
 * half-drawn rather than as the empty slot; the dashes already say "empty", so the colour does
 * not have to be quiet as well.
 */
function NewAgentCard({ creating, onCreate }: { creating: boolean; onCreate: () => void }) {
  return (
    <button
      type="button"
      data-testid="agents-grid-new-card"
      disabled={creating}
      onClick={onCreate}
      // The same card as its siblings — same surface at 75%, same padding, same shadow, same
      // hover to a full-opacity surface — differing ONLY in the dashed border. Drawn without a
      // background it read as a hole in the grid rather than as the next card in it.
      className="flex min-h-[6.5rem] cursor-pointer flex-col rounded-xl border border-dashed border-foreground/30 bg-surface/75 p-3.5 text-left shadow-sm transition-colors hover:bg-surface disabled:cursor-default disabled:opacity-50"
    >
      <div className="flex min-w-0 items-center gap-2.5">
        {/* The plus wears the same tile the Playbook and Knowledge base cards give their
            icons, and every file row gives its type glyph — `bg-muted`, rounded, icon at
            `h-4 w-4`. Sized to the agent avatar beside it so the row does not step. A bare
            glyph floating where every other card has a filled shape read as a missing image. */}
        <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/70">
          <Plus aria-hidden className="h-4 w-4" />
        </span>
        <span className="min-w-0 flex-1 truncate text-base font-semibold text-foreground">
          {creating ? 'Creating…' : NEW_AGENT_NAME}
        </span>
      </div>
      <p className="mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
        Create new agent
      </p>
    </button>
  );
}

/**
 * One agent.
 *
 * The avatar and the name carry the card — they are what you are scanning for, and at
 * `size-6` / `text-sm` they were the same weight as the description under them, so a grid
 * of twenty read as twenty paragraphs rather than twenty agents.
 *
 * `scope: 'org'` is the one fact about an agent that changes what editing it MEANS —
 * everyone on the team gets your change — so it is on the card, as a badge, rather than
 * something you discover after opening it. Nothing else is: the enabled/disabled dot that
 * used to sit here was a 6px unlabelled colour on every card, which is a legend to learn,
 * and the workspace says it in words the moment you arrive.
 */
function AgentCard({ agent, onOpen }: { agent: AgentSummary; onOpen: () => void }) {
  return (
    <button
      type="button"
      data-testid="agents-grid-card"
      onClick={onOpen}
      className="flex min-h-[6.5rem] cursor-pointer flex-col rounded-xl border border-border bg-surface/75 p-3.5 text-left shadow-sm transition-colors hover:border-foreground/30 hover:bg-surface"
    >
      <div className="flex min-w-0 items-center gap-2.5">
        <AgentAvatar
          agentId={agent.agentId}
          avatar={agent.avatar}
          mood="idle"
          className="size-7 shrink-0"
        />
        <span className="min-w-0 flex-1 truncate text-base font-semibold text-foreground">
          {agentDisplayName(agent.name)}
        </span>
      </div>
      <p className="mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
        {agent.description ?? ''}
      </p>
    </button>
  );
}