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