AgentConnectionsSection.tsx36.4 KBView on GitHub
'use client';

import {
  familyTargets,
  familyVerbGroups,
  meetingsTargets,
  recorderTargets,
  recorderVerbGroups,
  verbGranted,
  type AgentConnectionScopes,
  type AgentScopeTarget,
} from '@/modules/agents/utils/connection-scopes';
import { ConnectionInitial, connectionLogo } from '@/modules/agents/utils/connection-logo';
import {
  getMeetingProviderLogo,
  getMeetingProviderName,
} from '@/modules/conversations/components/timeline/meetingProviderDisplay';
import { AGENT_SECTION_ADD_BUTTON, AGENT_SECTION_SURFACE } from './AgentConfigPage';
import type {
  AgentConnection,
  AgentConnectionRecorder,
  AgentConnectionTool,
} from '@/modules/agents/types';
import { DriveReauthCard } from '@/modules/integrations/drive-reauth-card';
import { AnimatePresence, motion } from 'motion/react';
import { Skeleton } from '@/components/ui/skeleton';
import { ChevronRight, Plus } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useState } from 'react';

/**
 * Settings still owns the full connection manager — editing credentials, the
 * permission checklist, disconnecting. What it no longer owns is the ANSWER to
 * "give this agent something to reach": that is resolved in
 * `AgentAddConnectionDialog` and `AgentFamilyConnectDialog`, on this page, where
 * the question was asked.
 *
 * NOTHING on an agent's config page navigates any more. The rule is not cosmetic:
 * a link off this page drops the question you came here to answer ("can THIS agent
 * reach X") and lands you somewhere with no idea which agent sent you. This
 * constant survives only for surfaces rendered WITHOUT an agent, where there is no
 * agent to keep you next to.
 */
export const MCP_SETTINGS_HREF = '/settings/connections';

/**
 * One duration for every height change on this card — the list opening, a row's
 * tools opening inside it — so a tool disclosure inside an opening list reads as one
 * movement rather than two racing each other. A tween, not a spring: a spring
 * overshoots, and overshooting a height means the rows below it bounce.
 */
const HEIGHT_TRANSITION = { type: 'tween', duration: 0.2, ease: 'easeOut' } as const;

export interface AgentConnectionsView {
  defaults: AgentConnection[];
  mcp: AgentConnection[];
}

/**
 * Live means "this agent can actually call it right now" — connected AND, for an
 * MCP server, granted to this agent. A connected-but-ungranted server is exactly
 * the gap the picker exists to close, so it rings grey with the disconnected ones
 * rather than green with the working ones.
 */
function isLive(connection: AgentConnection): boolean {
  if (connection.status !== 'connected') return false;
  return !(connection.kind === 'mcp' && !connection.granted);
}

/**
 * One connection's mark, in its status ring — the SAME element in both states.
 *
 * `layoutId` is what makes the two states one gesture: the badge in the collapsed
 * ring and the badge on the expanded row are the same motion element, so opening
 * the list flies each icon out of the stack and into its own row rather than
 * cross-fading one cluster for another.
 */
function ConnectionBadge({
  connection,
  className,
}: {
  connection: AgentConnection;
  className?: string;
}) {
  const Logo = connectionLogo(connection.key, connection.name);
  return (
    <motion.span
      layoutId={`agent-connection-${connection.key}`}
      // Explicit and SHORT. Without it the badge takes motion's default spring, which
      // overshoots and settles — eight of those firing at once while the rows mount
      // underneath is what read as the whole list flashing on expand.
      transition={{ type: 'tween', duration: 0.18, ease: 'easeOut' }}
      className={cn(
        'bg-background flex h-6 w-6 shrink-0 items-center justify-center rounded-full ring-2',
        // Green is "working". Everything else — never connected, errored, or
        // connected but not granted here — reads as the one grey "needs you".
        isLive(connection) ? 'ring-emerald-500' : 'ring-border',
        className,
      )}
    >
      {Logo ? (
        <Logo className="h-3.5 w-3.5" />
      ) : (
        <ConnectionInitial
          name={connection.name}
          className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-[9px] font-bold leading-none text-white"
        />
      )}
    </motion.span>
  );
}

/**
 * ONE RECORDER'S VENDOR MARK — Circleback's, Gong's, whichever the row names.
 *
 * A recorder's mark is a PNG/SVG under public/logos/meetings, so it arrives as a PATH where
 * every other mark on this screen is a component. Kept as its own small piece rather than
 * wrapped in a component-shaped adapter: the adapter would exist only to make an `<img>`
 * look like an icon, and `connection-logo.tsx` would then need a second registry beside the
 * one the conversation timeline already keeps. Same marks, one registry, two shapes.
 *
 * Clari and Caretta ship no mark, so they fall back to the monogram like any other
 * unbranded connection — an empty box would read as a broken image, not as "no logo".
 */
function RecorderMark({ providerId, name }: { providerId: string; name: string }) {
  const logoSrc = getMeetingProviderLogo(providerId);
  return (
    <span className="bg-background border-border flex h-5 w-5 shrink-0 items-center justify-center rounded-full border">
      {logoSrc ? (
        // `alt=""` — the row's own text names the recorder, so a second reading of the same
        // word is noise to a screen reader, not information.
        <img src={logoSrc} alt="" className="h-3 w-3 rounded-[2px] object-contain" />
      ) : (
        <ConnectionInitial
          name={name}
          className="inline-flex h-3 w-3 items-center justify-center rounded-full text-[8px] font-bold leading-none text-white"
        />
      )}
    </span>
  );
}

/**
 * The collapsed state: every connection as one overlapping ring of badges.
 *
 * Copied from the attendee stack on a meeting card, because it answers the same
 * question in the same glance — who/what is on this thing, and is any of it
 * unresolved. A list of eight rows to say "all eight are fine" is eight rows of
 * screen for no decision; the ring says it in one, and the only thing worth
 * reading in detail — a grey badge — is still visible without opening anything.
 */
function ConnectionRing({ rows }: { rows: AgentConnection[] }) {
  return (
    // A SPAN, not a button: it sits inside the row button that opens the list, and
    // the badges are not eight little targets — there is no aiming to do.
    <span className="flex -space-x-1.5">
      {rows.map((c) => (
        // The lift is on a PLAIN wrapper, not on the badge: the badge is a
        // shared-`layoutId` element and motion writes its `transform` during
        // the flight, so a hover transform on the same node would be fighting
        // an inline style it cannot win against.
        <span
          key=[redacted]
          className="relative transition-transform duration-150 hover:z-10 hover:-translate-y-0.5"
        >
          <ConnectionBadge connection={c} />
        </span>
      ))}
    </span>
  );
}

/**
 * The Drive family's key, as `DEFAULT_CONNECTION_FAMILIES` in
 * services/agent-workspace/connections.ts spells it. Named because Drive is the one row
 * whose `error` status has a specific remedy — a Google re-consent — rather than a
 * Connect button.
 */
const DRIVE_CONNECTION_KEY=[redacted];

/**
 * The three indents this screen has, and it has exactly three.
 *
 * NESTING IS PADDING, NEVER A SECOND SURFACE. A bordered box inside the card would be two
 * frames drawn around one thing (CLAUDE.md → one surface per object), and it would be worst
 * at the deepest level, where the box would be a box inside a box inside a card. Depth is
 * read off the left edge instead, and each step lands the label roughly under the NAME of
 * the thing above it rather than under its mark — so the column reads as an outline.
 *
 *   row     px-3         Gmail, Meetings, an MCP server
 *   detail  pl-[3.25rem] a family's verb, an MCP tool, a recorder
 *   nested  pl-[4.75rem] one recorder's verb
 */
const DETAIL_ROW_INDENT = 'pl-[3.25rem]';
const NESTED_ROW_INDENT = 'pl-[4.75rem]';

/**
 * A recorder's one line: what it delivers, and whether Cedar can pull from it.
 *
 * Both halves, always, because either one alone is the old lie in a new place. "Connected"
 * on its own is what the generic Meetings row said about a Gong that had produced zero
 * transcripts; "432 meetings" on its own hides that Cedar cannot fetch a 433rd, which is
 * exactly what `sync-meeting` will refuse to do.
 *
 * "not connected" is stated plainly and NOT dressed as a fault: a recorder that fans its
 * meetings out to every attendee by webhook delivers to people who have no credential for
 * it, and that is a normal, working arrangement — the transcripts are here and readable.
 */
function recorderSummary(recorder: { connected: boolean; deliveredCount: number }): string {
  const delivered =
    recorder.deliveredCount === 0
      ? 'no meetings yet'
      : `${recorder.deliveredCount.toLocaleString()} meeting${recorder.deliveredCount === 1 ? '' : 's'}`;
  return `${delivered} · ${recorder.connected ? 'connected' : 'not connected — arrives by webhook'}`;
}

/**
 * ONE row inside a connection's disclosure — used for an MCP server's tools AND for a
 * family's verbs.
 *
 * Deliberately one component and not two. Both halves of this screen answer the same
 * question in the same shape ("this capability, on or off, for this agent"), and the
 * moment they are two components they drift into two indent levels, two switch sizes and
 * two vocabularies — which is the split §3.3 of the design doc says must not happen.
 */
function ConnectionDetailRow({
  label,
  name,
  hint,
  granted,
  disabled,
  blocked,
  nested,
  onToggle,
}: {
  /** What the row is called on screen: a tool name, or a verb ("Send"). */
  label: string;
  /**
   * What the switch is called to a screen reader. Verbs need the connection's name in
   * front of them — "Send" alone is ambiguous the moment two families are open at once.
   */
  name: string;
  /** ONE line under the label — CLAUDE.md → Forms → the row. */
  hint?: string | null;
  granted: boolean;
  disabled: boolean;
  /** The CONNECTION forbids it (fix in Settings), not merely this agent (fix here). */
  blocked?: boolean;
  /**
   * One level deeper — a recorder's own verb, under the recorder, under Meetings.
   *
   * A PROP, not a second component. The third level differs from the second by its left
   * padding and by nothing else, and the moment it is its own component it drifts into its
   * own type scale and its own switch size, which is the split this component exists to
   * prevent.
   */
  nested?: boolean;
  onToggle: (granted: boolean) => void;
}) {
  return (
    <li
      // Indented by PADDING, not by a margin: the tint has to reach both edges of the
      // container like every other row, and a margin would stop it short on the left.
      // `py-2`, not `py-1.5`: the type below is a step larger, so the old padding left the
      // rows touching.
      className={cn(
        'hover:bg-sunken flex items-center gap-2 py-2 pr-3 transition-colors',
        nested ? NESTED_ROW_INDENT : DETAIL_ROW_INDENT,
      )}
    >
      <span className="flex min-w-0 flex-col gap-0.5">
        {/*
          READABLE SIZE, AND NOT TRUNCATED. These rows were `text-xs` over `text-[11px]`,
          both clipped with an ellipsis — the smallest type on the screen used for the one
          thing the screen exists to let you decide. A permission you have to lean in to
          read is one you skim, and a hint cut off mid-sentence ("Acts on the account for
          real — messages, invit…") loses exactly the half that tells you what it costs.
          Sized like the rest of the app instead: `text-sm` label, `text-xs` hint, both
          allowed their full width. The hint stays ONE line by being written short
          (CLAUDE.md → Forms → the row), not by being clipped.
        */}
        <span className={cn('text-sm', granted ? 'text-foreground' : 'text-muted-foreground')}>
          {label}
        </span>
        {hint && <span className="text-muted-foreground text-xs">{hint}</span>}
      </span>
      {/* One word, not a badge: the difference that matters is whether the CONNECTION
          forbids it or this agent merely is not granted it. */}
      {blocked && <span className="text-muted-foreground shrink-0 text-xs">blocked</span>}
      <Switch
        checked={granted}
        disabled={disabled}
        onCheckedChange={onToggle}
        aria-label={`${name} enabled for this agent`}
        className="ml-auto"
      />
    </li>
  );
}

/**
 * THE MIDDLE LEVEL — one recorder under the Meetings row, and its own actions under it.
 *
 * A recorder is not a family and it is not a verb, and both of the flattenings that were
 * tried lost something real. As part of the family it had no switch of its own, so
 * "Circleback yes, Gong no" — a distinction `find-meetings` genuinely enforces in its
 * `WHERE` — could not be said. As a top-level row it stood beside Gmail as one of twelve
 * peers, and the user with no recorder at all got no row and therefore no way to connect
 * one. Here it is what it is: a thing the Meetings row contains, which itself contains
 * actions.
 *
 * It reuses `ConnectionDetailRow` for its own verbs at `nested` depth, so the third level is
 * one indent step and NOT a second bordered surface (CLAUDE.md → one surface per object).
 */
function RecorderRow({
  recorder,
  scopes,
  onToggleScope,
  isMutating,
}: {
  recorder: AgentConnectionRecorder;
  scopes?: AgentConnectionScopes;
  onToggleScope?: (targets: AgentScopeTarget[], granted: boolean) => void;
  isMutating?: boolean;
}) {
  const [open, setOpen] = useState(false);
  // The recorder's OWN actions, off the recorder — a webhook-only one exposes `find` and not
  // `sync`, so it grows a Read row and no Attach row. See `recorderVerbGroups`.
  const verbs = recorderVerbGroups(recorder);
  const targets = recorderTargets(recorder);
  const name = getMeetingProviderName(recorder.providerId);
  const expandable = verbs.length > 0;

  return (
    <li className="flex flex-col">
      <div
        className={cn(
          'flex items-center gap-2 py-2 pr-3 transition-colors',
          DETAIL_ROW_INDENT,
          expandable && 'hover:bg-sunken',
        )}
      >
        <button
          type="button"
          onClick={() => expandable && setOpen((v) => !v)}
          className={cn(
            'flex min-w-0 flex-1 items-center gap-2 text-left',
            expandable && 'cursor-pointer',
          )}
          aria-expanded={expandable ? open : undefined}
          disabled={!expandable}
        >
          <RecorderMark providerId={recorder.providerId} name={name} />
          {/* Same treatment an ungranted verb row gets: the name dims rather than the row
              growing a badge, so "off" reads the same at every depth. */}
          <span
            className={cn(
              'truncate text-sm',
              recorder.granted ? 'text-foreground' : 'text-muted-foreground',
            )}
          >
            {name}
          </span>
          {/* BOTH truths, on one line. Either half alone is the old generic row's lie in a
              new place — see `recorderSummary`. */}
          <span className="text-muted-foreground truncate text-xs">
            {recorderSummary(recorder)}
          </span>
          {expandable && (
            <ChevronRight
              className={cn(
                'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
                open && 'rotate-90',
              )}
            />
          )}
        </button>
        <Switch
          checked={recorder.granted}
          disabled={targets.length === 0 || !onToggleScope || !!isMutating}
          onCheckedChange={(next) => onToggleScope?.(targets, next)}
          aria-label={`${name} enabled for this agent`}
          className="ml-auto shrink-0"
        />
      </div>

      <AnimatePresence initial={false}>
        {open && (
          <motion.ul
            key=[redacted]
            className="flex flex-col overflow-hidden"
            initial={{ height: 0 }}
            animate={{ height: 'auto' }}
            exit={{ height: 0 }}
            transition={HEIGHT_TRANSITION}
          >
            {verbs.map((verb) => (
              <ConnectionDetailRow
                key=[redacted]
                nested
                label={verb.label}
                name={`${name} ${verb.label}`}
                hint={verb.hint}
                granted={verbGranted(scopes, verb)}
                disabled={!onToggleScope || !!isMutating}
                onToggle={(next) => onToggleScope?.(verb.targets, next)}
              />
            ))}
          </motion.ul>
        )}
      </AnimatePresence>
    </li>
  );
}

/**
 * One row per connection: name, a disclosure for its verbs, recorders or tools, and its
 * switch.
 *
 * There is deliberately NO "Cedar capabilities" / "MCP connections" split. That
 * division was an implementation detail leaking into the UI — Gmail is reached
 * through Cedar's own tools and Stripe through an MCP server, but to the person
 * deciding what this agent may touch they are the same question, answered in one
 * list. The split also produced two headings, two empty states and two tool
 * vocabularies on a screen whose whole job is "what can this reach".
 *
 * FIVE row states, and they are five different questions:
 *   not connected → a Connect button. There is nothing to switch yet, and Meetings with no
 *     recorder at all is exactly this row — the state the flat recorder rows could not draw,
 *     which left the user who most needs a notetaker with nothing on screen to click.
 *   Drive, connected on the LEGACY scope → the re-consent card, NOT a Connect button:
 *     the account is linked, the grant is too narrow, and "Connect" would send the user
 *     round a flow they have already completed.
 *   MCP, connected → a switch, per server AND per tool. Both write `mcp_servers:`.
 *   Meetings, with recorders → a switch, one switch per recorder, and one switch per action
 *     under each recorder. THREE levels, all writing the same `kind: 'meeting'` entries.
 *   any other default family → a switch, and per-verb switches under it. Both write
 *     `metadata.connection_scopes` through `setConnectionScope`.
 */
function ConnectionRow({
  connection,
  scopes,
  onConnect,
  onToggle,
  onToggleTool,
  onToggleScope,
  isMutating,
}: {
  connection: AgentConnection;
  /** This agent's scope + the verb catalog. Absent = the family rows stay read-only. */
  scopes?: AgentConnectionScopes;
  /** Absent only when the section is rendered without an agent (tests, previews). */
  onConnect?: (connection: AgentConnection) => void;
  /** Grant/revoke the whole server. Absent = read-only (no tRPC half wired). */
  onToggle?: (connection: AgentConnection, granted: boolean) => void;
  /** Grant/revoke ONE tool of it. */
  onToggleTool?: (connection: AgentConnection, tool: AgentConnectionTool, granted: boolean) => void;
  /** Grant/revoke a family row or one of its verbs. */
  onToggleScope?: (targets: AgentScopeTarget[], granted: boolean) => void;
  isMutating?: boolean;
}) {
  const tools = connection.tools ?? [];
  const grantedCount = tools.filter((t) => t.granted).length;
  const isFamily = connection.kind === 'default';
  /**
   * The recorders, when this is the Meetings row — the level BETWEEN the row and its verbs.
   *
   * Present-and-empty is a real state (a user with no notetaker), which is why this reads
   * the field rather than the length: an empty Meetings row is `disconnected` and offers
   * Connect, and that is the regression the flat rows introduced.
   */
  const recorders = connection.recorders ?? [];
  // Derived from what the SERVER says the thing can do, never from a list kept here — a
  // family whose tools implement no `send-*` action has no Send row to render. For Meetings
  // that source is the recorders themselves, since their surface differs per user.
  const verbs =
    isFamily && !connection.recorders ? familyVerbGroups(connection.key, scopes?.catalog) : [];
  const rowTargets = connection.recorders
    ? meetingsTargets(recorders)
    : isFamily
      ? familyTargets(connection.key, scopes?.catalog)
      : [];
  const grantedVerbs = verbs.filter((v) => verbGranted(scopes, v)).length;
  const grantedRecorders = recorders.filter((r) => r.granted).length;
  /**
   * Connected on `drive.file` — the legacy picker-only grant. The server reports it as
   * `error` because it is stored-but-not-usable, and the remedy is a re-consent.
   */
  const needsReauth =
    isFamily && connection.key === DRIVE_CONNECTION_KEY && connection.status === 'error';
  const expandable = tools.length > 0 || verbs.length > 0 || recorders.length > 0 || needsReauth;
  const connected = connection.status === 'connected';
  // Open on mount when the row is the one asking for something: a re-consent hidden
  // behind a disclosure is the silently-empty Drive this card exists to prevent.
  const [open, setOpen] = useState(needsReauth);

  return (
    <div className="flex flex-col">
      {/* No divider. Rows in a card are separated by space — see CLAUDE.md → UI
          affordances. The padding is on the ROW so the hover tint has a margin
          around the name instead of clamping to it. */}
      <div
        className={cn(
          'flex items-center gap-2 px-3 py-2 transition-colors',
          expandable && 'hover:bg-sunken',
        )}
      >
        <button
          type="button"
          onClick={() => expandable && setOpen((v) => !v)}
          className={cn(
            'flex min-w-0 flex-1 items-center gap-2.5 py-1 text-left',
            expandable && 'cursor-pointer',
          )}
          aria-expanded={expandable ? open : undefined}
          disabled={!expandable}
        >
          <ConnectionBadge connection={connection} />
          <span className="truncate text-[15px] font-medium">{connection.name}</span>
          {expandable && (
            <ChevronRight
              className={cn(
                'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
                open && 'rotate-90',
              )}
            />
          )}
        </button>

        <div className="flex shrink-0 items-center gap-3">
          {tools.length > 0 && (
            <span className="text-muted-foreground text-xs tabular-nums">
              {grantedCount}/{tools.length}
            </span>
          )}
          {verbs.length > 0 && (
            <span className="text-muted-foreground text-xs tabular-nums">
              {grantedVerbs}/{verbs.length}
            </span>
          )}
          {/* The count is what makes "partially granted" legible, because the row switch
              cannot be: `granted` is ANY-of, so Meetings reads ON while two of its three
              recorders are off. Same job the MCP rows' count already does. */}
          {recorders.length > 0 && (
            <span className="text-muted-foreground text-xs tabular-nums">
              {grantedRecorders}/{recorders.length}
            </span>
          )}
          {!connected && !needsReauth ? (
            onConnect ? (
              <Button
                size="sm"
                className="h-6 cursor-pointer px-2.5 text-xs"
                onClick={() => onConnect(connection)}
              >
                Connect
              </Button>
            ) : (
              // No agent in scope, so there is nothing to stay next to.
              <Button asChild size="sm" className="h-6 cursor-pointer px-2.5 text-xs">
                <a href={MCP_SETTINGS_HREF}>Connect</a>
              </Button>
            )
          ) : (
            <Switch
              checked={connection.granted}
              /*
               * A default family used to be inert here, on the reasoning that Cedar's own
               * tools are scoped by the playbook's `permissions:` (a SKILL list) and that
               * writing an inverse skill list from a connection switch would freeze the
               * agent at today's skill catalog. That reasoning is now obsolete in both
               * halves: `permissions:` was never enforced on either path, and a family
               * grant no longer goes through skills at all — it writes
               * `metadata.connection_scopes`, whose vocabulary is the dispatch table's own
               * `(tool, action)` pairs, so a skill added tomorrow changes nothing here.
               *
               * What still makes a family switch inert is the honest reason: no catalog in
               * hand (`scopes` not loaded, or no tRPC half wired), which leaves nothing to
               * compute the write FROM.
               */
              disabled={
                isFamily
                  ? rowTargets.length === 0 || !onToggleScope || isMutating
                  : !onToggle || isMutating
              }
              onCheckedChange={(next) =>
                isFamily ? onToggleScope?.(rowTargets, next) : onToggle?.(connection, next)
              }
              aria-label={`${connection.name} enabled for this agent`}
            />
          )}
        </div>
      </div>

      <AnimatePresence initial={false}>
        {open && (
          <motion.ul
            key=[redacted]
            className="flex flex-col overflow-hidden"
            initial={{ height: 0 }}
            animate={{ height: 'auto' }}
            exit={{ height: 0 }}
            transition={HEIGHT_TRANSITION}
          >
            {needsReauth && (
              <li className="px-3 pb-2 pt-1">
                {/* The account IS linked; the stored grant predates Drive browsing. The
                    card asks for the one thing that fixes that — nothing on this screen
                    can widen an OAuth scope. */}
                <DriveReauthCard accessLevel="file" />
              </li>
            )}
            {recorders.map((r) => (
              <RecorderRow
                key=[redacted]
                recorder={r}
                isMutating={isMutating}
                {...(scopes ? { scopes } : {})}
                {...(onToggleScope ? { onToggleScope } : {})}
              />
            ))}
            {verbs.map((verb) => (
              <ConnectionDetailRow
                key=[redacted]
                label={verb.label}
                name={`${connection.name} ${verb.label}`}
                hint={verb.hint}
                granted={verbGranted(scopes, verb)}
                disabled={!onToggleScope || !!isMutating}
                onToggle={(next) => onToggleScope?.(verb.targets, next)}
              />
            ))}
            {tools.map((tool) => (
              <ConnectionDetailRow
                key=[redacted]
                label={tool.name}
                name={tool.name}
                hint={tool.description}
                granted={tool.granted}
                // `allowedByConnection` is the CEILING. A switch that could turn on
                // a tool the connection denies would be offering privilege
                // escalation, which a per-agent grant must never be.
                disabled={!tool.allowedByConnection || !onToggleTool || !!isMutating}
                blocked={!tool.allowedByConnection}
                onToggle={(next) => onToggleTool?.(connection, tool, next)}
              />
            ))}
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );
}

interface AgentConnectionsSectionProps {
  connections?: AgentConnectionsView;
  isLoading?: boolean;
  /**
   * Opens the right modal for a disconnected row. Omitted, the row falls back to
   * the Settings link — which only happens where no agent is in scope.
   */
  onConnect?: (connection: AgentConnection) => void;
  /**
   * The two grant writes, both OPTIONAL — without them this stays the read-only
   * list it was, which is what keeps it renderable with no tRPC provider in scope.
   * The container that owns the mutations is ./AgentConnectionsPanel.tsx.
   */
  onToggle?: (connection: AgentConnection, granted: boolean) => void;
  onToggleTool?: (connection: AgentConnection, tool: AgentConnectionTool, granted: boolean) => void;
  /**
   * This agent's connection scope AND the verb catalog it is written in — one object,
   * because a verb row can only be drawn against the catalog and only be checked against
   * the entries. Absent, the family rows render read-only, which is what keeps this
   * section mountable with no tRPC provider in scope.
   */
  scopes?: AgentConnectionScopes;
  /**
   * Grant/revoke a family row or one of its verbs. One call carries every
   * `(tool, actions)` target the control covers — Gmail's row is `mail` AND
   * `draft-comms: draft-email`, and a switch that wrote only one of them would leave the
   * agent half-granted with a switch that reads "off".
   */
  onToggleScope?: (targets: AgentScopeTarget[], granted: boolean) => void;
  /** A grant write is in flight; the switches go inert rather than queueing. */
  isMutating?: boolean;
  /**
   * Opens the connection picker. With it, the container ends in an "Add connection"
   * row; without it (no agent in scope) there is nothing to add TO, so there is no row.
   */
  onAdd?: () => void;
  /** Named on the Add row's fallback link, for the no-agent case. */
  agentId?: string;
  /** Start open. The page wants the ring; a test or preview may want the list. */
  defaultExpanded?: boolean;
  className?: string;
}

/**
 * The section's "+ Add" — rendered on the Config page's HEADING row, not above the
 * list.
 *
 * It opens the picker in place. It used to deep-link to Settings on the reasoning
 * that a connection is owned there and duplicating the flow is how one server
 * becomes two — but the dialog does not duplicate the flow, it calls the same
 * `addMcpConnection` / `initiateOAuth` mutations Settings calls. What the link
 * duplicated was the WALK: off the agent, connect, walk back, and grant.
 *
 * `agentId` is optional so the section can still be rendered without one; without
 * it the button falls back to the old link rather than opening a dialog that has no
 * agent to grant to.
 */
export function AgentConnectionsAddButton({
  agentId,
  onOpen,
  className,
}: {
  agentId?: string;
  onOpen?: () => void;
  className?: string;
}) {
  // `h-6 w-6`, the badge's box — so the label starts on the same edge as every
  // connection name above it.
  const icon = <Plus className="h-6 w-6 shrink-0 p-1" />;
  if (!agentId || !onOpen) {
    return (
      <a href={MCP_SETTINGS_HREF} className={cn(AGENT_SECTION_ADD_BUTTON, className)}>
        {icon}
        Add connection
      </a>
    );
  }
  return (
    <button type="button" className={cn(AGENT_SECTION_ADD_BUTTON, className)} onClick={onOpen}>
      {icon}
      Add connection
    </button>
  );
}

export function AgentConnectionsSection({
  connections,
  isLoading,
  onConnect,
  onToggle,
  onToggleTool,
  onToggleScope,
  scopes,
  onAdd,
  agentId,
  isMutating,
  defaultExpanded = false,
  className,
}: AgentConnectionsSectionProps) {
  const [expanded, setExpanded] = useState(defaultExpanded);

  if (isLoading) {
    return (
      <div className={cn('flex flex-col gap-2', className)}>
        <Skeleton className="h-9 w-full" />
        <Skeleton className="h-9 w-full" />
      </div>
    );
  }

  // One list. The two sources are an implementation detail, not a user-facing
  // taxonomy, so they are concatenated rather than sectioned.
  const rows = [...(connections?.defaults ?? []), ...(connections?.mcp ?? [])];
  const live = rows.filter(isLive).length;
  const pending = rows.length - live;

  if (rows.length === 0) {
    return (
      <div className={cn(AGENT_SECTION_SURFACE, 'flex flex-col overflow-hidden', className)}>
        {/* Nothing to expand, so there is no collapsed state to keep clean — the row
            is the only way out of an empty section. */}
        <p className="text-muted-foreground px-3 py-2 text-sm">No connections yet.</p>
        {onAdd && <AgentConnectionsAddButton agentId={agentId} onOpen={onAdd} />}
      </div>
    );
  }

  return (
    // ONE surface, and the count line lives INSIDE it: the sentence is part of the
    // object it counts, not a caption floating above the card.
    //
    // `overflow-hidden` and NO padding, so a row's hover surface spans the whole
    // container edge to edge and the card's own radius clips it. A padded card with
    // rounded rows inside tints a floating pill and leaves an untinted margin around
    // it — which reads as hovering the text rather than the row.
    <div className={cn(AGENT_SECTION_SURFACE, 'flex flex-col overflow-hidden', className)}>
      {/* Collapsed, this button IS the whole card — the badges sit inside it, so a
          click anywhere on the container opens the list. Expanded, the same row
          closes it again. */}
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        aria-expanded={expanded}
        aria-label={expanded ? 'Hide connections' : 'Show connections'}
        className="hover:bg-sunken flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors"
      >
        {/* No AnimatePresence: the badges are shared `layoutId` elements, so the ring
            must UNMOUNT in the same commit the rows mount for motion to fly them into
            place. Keeping it alive during an exit would leave two elements claiming
            one layoutId. */}
        {!expanded && <ConnectionRing rows={rows} />}
        <span className="text-muted-foreground text-sm">
          {live} connected
          {pending > 0 && <span className="text-foreground"> · {pending} to connect</span>}
        </span>
        {/*
          NO inherit/scoped badge. `mode` is a real distinction in the STORE — absent scopes
          mean "reaches everything, including what is connected tomorrow" — but it is the
          storage layer's vocabulary, not the reader's, and on the card it asked them to
          hold a concept in order to interpret switches that already say what is on. The
          switches are the answer; the badge was a footnote about how the answer is filed.
          The distinction still matters where it has consequences, and is kept there: the
          server materialises `inherit` before applying any change (setConnectionScope), so
          the first click cannot silently revoke everything it did not mention.
        */}
        <ChevronRight
          className={cn(
            'text-muted-foreground ml-auto h-4 w-4 shrink-0 transition-transform',
            expanded && 'rotate-90',
          )}
        />
      </button>

      {/*
        The card GROWS to the list rather than jumping to its final height. The jump
        was worst exactly where the height is least predictable — a row with its tools
        open — because the container's height is the sum of things the user cannot see
        before they appear.
       
        `height: auto` on the animate step: motion measures the content and tweens the
        pixel value, so this keeps working when a row expands its tools inside an
        already-open list. No opacity here — the badges already move (shared
        `layoutId`, flying out of the ring into their rows) and fading the list in on
        top of that is what read as a flash.
       
        The Add row lives INSIDE the collapsible, so it travels with the list instead
        of snapping into place under a still-animating one. Collapsed, the card is one
        sentence and one click target; a second row under it would take the "click
        anywhere to expand" claim away from half of it.
      */}
      <AnimatePresence initial={false}>
        {expanded && (
          <motion.div
            key=[redacted]
            className="flex flex-col overflow-hidden"
            initial={{ height: 0 }}
            animate={{ height: 'auto' }}
            exit={{ height: 0 }}
            transition={HEIGHT_TRANSITION}
          >
            {rows.map((c) => (
              <ConnectionRow
                key=[redacted]
                connection={c}
                isMutating={isMutating}
                {...(scopes ? { scopes } : {})}
                {...(onConnect ? { onConnect } : {})}
                {...(onToggle ? { onToggle } : {})}
                {...(onToggleTool ? { onToggleTool } : {})}
                {...(onToggleScope ? { onToggleScope } : {})}
              />
            ))}
            {onAdd && <AgentConnectionsAddButton agentId={agentId} onOpen={onAdd} />}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}