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