HomeAgentsWidget.tsx8.4 KBView on GitHub 'use client';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { useMemo, useState } from 'react';
import { Bot, Plus } from 'lucide-react';
import { useHomeSettingList } from '@/modules/home/hooks/use-home-setting-list';
import { agentDisplayName } from '@/modules/agents/utils/agent-name';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import type { AgentSummary } from '@/modules/agents/types';
import { AgentPickerDialog } from './AgentPickerDialog';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { WidgetFrame } from './WidgetFrame';
/** Nothing is pinned until the user pins it — see the note in the widget below. */
const NO_AGENTS: readonly string[] = [];
/** The fallback second line: an agent with no description still has a history. */
function lastRunLabel(lastRunAt: string | null): string {
if (!lastRunAt) return 'Never run';
const parsed = new Date(lastRunAt);
if (Number.isNaN(parsed.getTime())) return 'Never run';
return `Ran ${formatDistanceToNow(parsed, { addSuffix: true })}`;
}
/**
* The home rail's hand-picked agent shortlist.
*
* EMPTY BY DEFAULT, deliberately. A shortlist the user did not choose is just the agent list
* again, one column narrower — and a fresh account seeding it with the system agents would be
* saying "here are seven things to go and talk to", which is the impression the folders exist
* to avoid.
*
* This is a SELECTION, not a folder. `homeAgentIds` lives beside the folders rather than
* replacing them: an agent can sit in `core` and also be pinned home, and pinning must never
* re-file it.
*/
export function HomeAgentsWidget() {
const trpc = useTRPC();
/**
* The agent workspace is a DISPLAY ARTIFACT, not a route.
*
* `navigate('/agents/:id')` is what this used to do and it looked like a no-op: the address
* changed, `LayoutUrlSync` mirrored `?agentId` back onto the artifact, and the router bounced
* straight back to the home layout. Setting the artifact is the actual open — LayoutUrlSync
* then writes `?agentId=<id>` so a reload comes back to the same agent.
*/
const setSelectedArtifact = useCedarStore((state) => state.setSelectedArtifact);
const [pickerOpen, setPickerOpen] = useState(false);
const { value: pinnedIds, setValue: setPinnedIds } = useHomeSettingList(
'homeAgentIds',
NO_AGENTS,
);
const { data: agents } = useQuery(trpc.agent.list.queryOptions());
/**
* Resolved in the ORDER THE USER PINNED THEM, and silently skipping ids that no longer
* resolve. A deleted agent leaves its id behind in settings, and a widget that blanked out
* (or threw) because of one stale string would be unrecoverable from the UI.
*/
const pinned = useMemo(() => {
const byId = new Map((agents ?? []).map((a) => [a.agentId, a]));
return pinnedIds.map((id) => byId.get(id)).filter((a): a is AgentSummary => !!a);
}, [agents, pinnedIds]);
const togglePinned = (agentId: string) =>
setPinnedIds(
pinnedIds.includes(agentId)
? pinnedIds.filter((id) => id !== agentId)
: [...pinnedIds, agentId],
);
return (
<>
<WidgetFrame
title="Agents"
icon={Bot}
action={
<button
type="button"
onClick={() => setPickerOpen(true)}
className="inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-sunken hover:text-foreground focus-visible:opacity-100 group-hover/widget:opacity-100"
>
<Plus aria-hidden className="h-3.5 w-3.5 shrink-0" />
Add agent
</button>
}
>
{pinned.length === 0 ? (
// The empty state is the SAME action as the hover control, spelled out. An empty
// widget whose only affordance appears on hover reads as broken on a touch screen
// and as decoration everywhere else.
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full cursor-pointer rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-sunken hover:text-foreground"
>
Add an agent to your home screen
</button>
) : (
<ul className="flex flex-col gap-0.5">
{pinned.map((agent) => (
<li key=[redacted]
<button
type="button"
data-testid="home-agent-row"
onClick={() => setSelectedArtifact({ kind: 'agent', id: agent.agentId })}
// The row bleeds 6px into the widget body's `px-3` on BOTH sides, so its
// content sits on the body's own content edge — in line with the frame's
// header icon and with the meeting times beside it — while the hover fill
// still extends past the text to read as a target rather than a highlighter
// pen. Symmetric, so the fill keeps air before the card's border on the
// right instead of running up against it.
//
// The width is EXPLICIT because this is a `<button>`. A negative margin
// widens a block box whose width is `auto` — but a form control's `auto`
// width is shrink-to-fit even at `display:flex`, so the row sized to its
// longest line instead, `truncate` never had a width to truncate against,
// and the description ran out through the side of the card. (The agenda's
// compact row gets away with a bare `-mx-2` because it is a `div`.)
//
// SUNKEN, not accent. In dark mode `--accent` IS `--surface-raised`, so an
// accent hover painted the row a colour barely off the widget behind it and
// the hover disappeared; in light mode `--accent` already resolves to
// `--surface-sunken`, which is why only dark mode looked wrong. One fill,
// both themes, identical to the meeting rows beside it.
className="-mx-1.5 flex w-[calc(100%+0.75rem)] cursor-pointer items-start gap-2.5 rounded-sm px-1.5 py-1.5 text-left transition-colors hover:bg-sunken"
>
{/* Fixed-width avatar column so the names start on one edge down the
list — the rail is narrow enough that a ragged left reads as noise. */}
<AgentAvatar
agentId={agent.agentId}
avatar={agent.avatar}
mood="idle"
className="mt-1 h-5 w-5 shrink-0"
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{agentDisplayName(agent.name)}
</span>
<span
role="img"
aria-label={agent.enabled ? 'Enabled' : 'Disabled'}
title={agent.enabled ? 'Enabled' : 'Disabled'}
className={
agent.enabled
? 'h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500'
: 'h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40'
}
/>
</span>
{/* Second line: what it does, or — failing that — when it last ran.
A row that is only a name says nothing a pinned shortlist needs. */}
<span className="min-w-0 truncate text-xs text-muted-foreground">
{agent.description?.trim() || lastRunLabel(agent.lastRunAt)}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</WidgetFrame>
{/* Mounted only once opened — it holds a query of its own. */}
{pickerOpen && (
<AgentPickerDialog
open
onOpenChange={setPickerOpen}
pinnedIds={pinnedIds}
onTogglePinned={togglePinned}
/>
)}
</>
);
}