AgentSourcesSection.tsx24.5 KBView on GitHub
'use client';

import {
  Clock,
  Zap,
  Video,
  GitBranch,
  Webhook,
  Bot,
  HelpCircle,
  Plus,
  Trash2,
  ExternalLink,
} from 'lucide-react';
import type { AgentInvocationSource, PlaybookTriggerRef } from '@/modules/agents/types';
import { AGENT_SECTION_ADD_BUTTON, AGENT_SECTION_SURFACE } from './AgentConfigPage';
import { TRIGGER_EVENT_TYPES, type TriggerEventType } from '@/modules/agents/utils/trigger-events';
import { AddTriggerForm, type TriggerStageOption } from './AddTriggerForm';
import { FormError, TextareaField } from '@/components/ui/field';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useEffect, useRef, useState } from 'react';

/** A playbook row, narrowed — the only kind of source this page can edit. */
export type PlaybookSource = Extract<AgentInvocationSource, { kind: 'playbook' }>;

/**
 * A trigger this form can build ON ITS OWN, from typed values alone.
 *
 * `webhook` is still not one of these, and for the original reason: its other half
 * is a `playbook_webhooks` row holding the token the inbound URL is addressed by,
 * and no amount of typing in a form produces that. What changed is that the form no
 * longer stops there — a webhook is now a `NewSourceRequest` of its own kind, which
 * the container fulfils with `agent.createWebhookSource` (mint the row, then patch
 * the playbook, deleting the row if the patch fails). So the picker CAN offer it,
 * and the two halves are still written by one call that cannot leave you with one.
 */
export type NewPlaybookTrigger = Exclude<PlaybookTriggerRef, { type: 'webhook' }>;

/**
 * What "add a source" submits.
 *
 * Two kinds because they are two different acts against two different backends —
 * `upsertPlaybookSource` patches a document, `createWebhookSource` mints a row AND
 * patches a document — and collapsing them into one optional-field shape would put
 * the difference in a runtime check instead of in the type.
 */
export type NewSourceRequest =
  | { kind: 'trigger'; trigger: NewPlaybookTrigger }
  | { kind: 'webhook'; label: string };

/** The picker's own value space: the typed triggers, plus the minted one. */
/**
 * What went wrong, split the one way the user can act on.
 *
 * `conflict` is its own kind because it is the only failure whose fix is
 * "reload" rather than "change what you typed" — the optimistic-concurrency
 * guard fires when someone else (or the agent itself) edited PLAYBOOK.md since
 * this page read it, and retrying the same submit would fail identically.
 */
export interface AgentSourcesError {
  kind: 'conflict' | 'error';
  message: string;
}

function iconFor(source: AgentInvocationSource) {
  if (source.kind === 'agent') return Bot;
  if (source.kind === 'unknown') return HelpCircle;
  switch (source.trigger.type) {
    case 'cron':
      return Clock;
    case 'before_meeting':
      return Video;
    case 'field_change':
      return GitBranch;
    case 'webhook':
      return Webhook;
    default:
      return Zap;
  }
}

/**
 * One row's identity — for React's `key` AND for the armed delete-confirm, which must
 * be the same string or the question and the answer can come to name different rows.
 *
 * `sourceId` is the stable handle the playbook writer mints for exactly this ("so
 * 'delete this row' can be aimed at one block even when a sibling has the identical
 * shape"). It is optional forever — nothing written before the attribute existed has
 * one and there is no backfill — so a row without it falls back to shape-plus-position,
 * which is how every row was addressed before ids existed. The effect above is what
 * covers that remainder: a positional key is safe as long as nothing is armed across a
 * change to the list.
 */
function sourceKey(source: AgentInvocationSource, i: number): string {
  if (source.kind === 'playbook' && source.sourceId) return `playbook:${source.sourceId}`;
  return `${source.kind}:${source.label}:${i}`;
}

interface AgentSourcesSectionProps {
  sources?: AgentInvocationSource[];
  /** Agent-level 30-day total — the only honest denominator (see below). */
  runCount30d?: number;
  isLoading?: boolean;
  className?: string;
  /**
   * Wiring for the two mutations, both OPTIONAL: without them this stays the
   * read-only list it has always been, which is what keeps it renderable in a
   * test (and anywhere else) with no tRPC provider in scope. The container that
   * owns the mutations is ./AgentSourcesPanel.tsx.
   */
  onAddSource?: (request: NewSourceRequest) => void;
  onDeleteSource?: (source: PlaybookSource) => void;
  /**
   * Write this row's per-trigger instruction — the `<ref>` body in PLAYBOOK.md.
   *
   * Optional like the other two, and its absence is not just "no callback": a row
   * with no instruction and no way to write one renders NO field at all, rather
   * than an empty box with a placeholder promising an edit that cannot happen.
   * An instruction that IS there still shows, read-only, because it is the thing
   * the trigger will actually say to the agent.
   */
  onUpdateInstructions?: (source: PlaybookSource, instructions: string) => void;
  /** A write is in flight; both affordances go inert rather than queueing. */
  isMutating?: boolean;
  error?: AgentSourcesError | null;
  /**
   * "Add a source", CONTROLLED — because the affordance now lives on the Config
   * page's heading row for this section, which is rendered by a different
   * component. Uncontrolled (both omitted) the section keeps its own state and
   * draws its own button, which is what every render outside that page does.
   */
  adding?: boolean;
  onAddingChange?: (adding: boolean) => void;
  /**
   * The playbook's stages, for the add-form's "Where" dropdown. Supplied by the
   * container (it holds the AOP); absent, the form simply offers no stage choice
   * rather than a text box that can name one the playbook does not have.
   */
  stages?: TriggerStageOption[];
  /**
   * The setup panel under a webhook row (copy URL, copy cURL, send a test).
   *
   * A render prop, and optional, for the same reason the mutations are: that panel
   * fires `aop.testFirePlaybookWebhook`, and a component that needs a query client
   * to render at all cannot be tested on what it renders. The container supplies it.
   */
  renderWebhookPanel?: (webhookId: string, url: string | null) => React.ReactNode;
}

/**
 * Config §1 — everywhere this agent is invoked from.
 *
 * Only things that are CONFIGURED appear here. A chat and a "Run now" are things
 * that happened, not configuration: a chat is listed under Previous Runs and Run
 * now is a header button, so neither is a row. Post-API endpoints are calls the
 * agent makes outward and belong under Connections.
 *
 * A `playbook` row shows `—` for its run count rather than a number, because
 * `agent_executions` does not record which trigger block fired a run. The
 * "has this ever actually fired?" question is therefore answered ONCE, at the
 * agent grain, in the header line — where it is computable.
 *
 * EDITABLE rows are `playbook` rows only. An `agent` row is another agent's
 * document: "deleting" it would mean editing a body this page is not looking at,
 * so it links there instead. An `unknown` row is a block shape this build cannot
 * name, and a delete aimed at something we cannot describe is not a delete.
 *
 * Presentational, props-in: every mutation arrives as a callback. See
 * ./AgentSourcesPanel.tsx for the tRPC half.
 */
export function AgentSourcesSection({
  sources,
  runCount30d = 0,
  isLoading,
  className,
  onAddSource,
  onDeleteSource,
  onUpdateInstructions,
  isMutating,
  error,
  adding,
  onAddingChange,
  stages,
  renderWebhookPanel,
}: AgentSourcesSectionProps) {
  const [ownAdding, setOwnAdding] = useState(false);
  const isAdding = adding ?? ownAdding;
  const setAdding = onAddingChange ?? setOwnAdding;
  /** The row awaiting confirmation, by its list key. Null = nothing armed. */
  const [confirming, setConfirming] = useState<string | null>(null);

  /**
   * The armed confirm is dropped the moment the LIST changes underneath it.
   *
   * "Remove this trigger?" is armed on a row and its Remove closes over that row's
   * source. The list is rebuilt from the playbook after every add and every delete, so
   * a confirm left armed across one of those is a confirm whose key can now name a
   * DIFFERENT row — the question renders under a trigger nobody pointed at, and
   * answering it deletes that one. Re-arming is one click; deleting the wrong trigger
   * is not undoable from here.
   */
  const signature = (sources ?? []).map((source, i) => sourceKey(source, i)).join('|');
  const seenSignature = useRef(signature);
  useEffect(() => {
    if (seenSignature.current === signature) return;
    seenSignature.current = signature;
    setConfirming(null);
  }, [signature]);

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

  const list = sources ?? [];

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      {list.length > 0 && runCount30d === 0 && (
        <div className="border-border bg-muted/40 text-muted-foreground rounded-md border px-3 py-2 text-sm">
          Configured, but this agent has not run in 30 days.
        </div>
      )}

      {error && (
        <FormError tone={error.kind === 'conflict' ? 'warning' : 'error'}>
          {error.kind === 'conflict'
            ? 'Someone else edited the playbook — reload the page and try again.'
            : error.message}
        </FormError>
      )}

      {/* The triggers are ONE object — "everything that fires this agent" — so they
          sit on one surface, and the line that COUNTS them sits on it too rather than
          floating above the card as a paragraph of its own. */}
      <div className={cn(AGENT_SECTION_SURFACE, 'flex flex-col overflow-hidden')}>
        {/* What the list IS, not how long it is — the rows underneath already say
            that, and "1 source" is a sentence spent on something you can see. */}
        <p className="text-muted-foreground px-3 py-2 text-sm">
          {list.length === 0 ? (
            'Nothing triggers this agent to run yet.'
          ) : (
            <>
              Things that trigger this agent to run · it ran{' '}
              <span className="text-foreground font-medium">{runCount30d}×</span> in the last 30
              days
            </>
          )}
        </p>

        {list.map((source, i) => {
          const key=[redacted], i);
          return (
            <SourceRow
              key=[redacted]
              source={source}
              confirming={confirming === key}
              onArm={() => setConfirming(key)}
              onCancel={() => setConfirming(null)}
              onDelete={
                onDeleteSource && source.kind === 'playbook'
                  ? () => {
                      setConfirming(null);
                      onDeleteSource(source);
                    }
                  : undefined
              }
              isMutating={isMutating}
              renderWebhookPanel={renderWebhookPanel}
              onUpdateInstructions={
                onUpdateInstructions && source.kind === 'playbook'
                  ? (instructions: string) => onUpdateInstructions(source, instructions)
                  : undefined
              }
            />
          );
        })}

        {/* The section's one action, as the last ROW of the list it adds to — same
            padding, same icon column, same hover as the triggers above it. */}
        {onAddSource && !isAdding && (
          <AgentSourcesAddButton onOpen={() => setAdding(true)} disabled={isMutating} />
        )}
      </div>

      {/* BELOW the list, because the row that opens it is the list's LAST one. A form
          that opens above the card pushes the whole list — and the button you just
          pressed — down the screen, so the thing you were looking at moves out from
          under the cursor at the moment you need to read it. */}
      {onAddSource && isAdding && (
        <AddTriggerForm
          onCancel={() => setAdding(false)}
          onSubmit={(trigger) => {
            setAdding(false);
            onAddSource(trigger);
          }}
          isMutating={isMutating}
          {...(stages ? { stages } : {})}
        />
      )}
    </div>
  );
}

/** "Add trigger" — the last row of the Triggers container. */
export function AgentSourcesAddButton({
  onOpen,
  disabled,
  className,
}: {
  onOpen: () => void;
  disabled?: boolean;
  className?: string;
}) {
  return (
    <button
      type="button"
      className={cn(AGENT_SECTION_ADD_BUTTON, className)}
      onClick={onOpen}
      disabled={disabled}
    >
      {/* `h-4 w-4`, like every trigger's icon — same glyph box, same column. */}
      <Plus className="h-4 w-4 shrink-0" />
      Add trigger
    </button>
  );
}

/**
 * What "every" is, spelled out.
 *
 * `On every event` is the only label on this list that names no thing — a schedule
 * says when, a field-change says which field, and this said "everything" without
 * ever saying what everything is. Four icons in brackets answer it in the width of
 * a word: email, meeting, Slack, external-CRM sync. Sourced from the same list the
 * add-form's Event picker reads, so a fifth event type appears in both or neither.
 */
/** A predicate, not a bare `.filter(e => e.Icon)`: only this narrows `Icon` for JSX. */
function hasGlyph(
  event: TriggerEventType,
): event is TriggerEventType & { Icon: NonNullable<TriggerEventType['Icon']> } {
  return event.Icon !== undefined;
}

function EveryEventGlyphs() {
  // The names, once, on the group — four adjacent tooltips reading one word each is
  // four hovers to learn what one hover can say, and four SVGs each claiming its own
  // accessible name is four stops in the reading order for one phrase.
  const names = TRIGGER_EVENT_TYPES.map((event) => event.short).join(', ');
  return (
    <span
      role="img"
      aria-label={`Every event: ${names}`}
      title={names}
      className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"
    >
      <span aria-hidden="true">(</span>
      {/*
        Only the events that HAVE a glyph. `external_crm` has none on purpose — there is no
        honest single mark for "whichever CRM you connected" — and it is still named in the
        `names` tooltip above, which is where the full list lives.
      */}
      {TRIGGER_EVENT_TYPES.filter(hasGlyph).map(({ value, Icon, tint }) => (
        <Icon key=[redacted] className={cn('h-3.5 w-3.5', tint)} aria-hidden="true" />
      ))}
      <span aria-hidden="true">)</span>
    </span>
  );
}

// ─── One row ──────────────────────────────────────────────────────────────────

function SourceRow({
  source,
  confirming,
  onArm,
  onCancel,
  onDelete,
  onUpdateInstructions,
  isMutating,
  renderWebhookPanel,
}: {
  source: AgentInvocationSource;
  confirming: boolean;
  onArm: () => void;
  onCancel: () => void;
  onDelete?: () => void;
  onUpdateInstructions?: (instructions: string) => void;
  isMutating?: boolean;
  renderWebhookPanel?: (webhookId: string, url: string | null) => React.ReactNode;
}) {
  const Icon = iconFor(source);
  const webhook =
    source.kind === 'playbook' && source.trigger.type === 'webhook' ? source.trigger : null;
  const isEveryEvent = source.kind === 'playbook' && source.trigger.type === 'any';

  return (
    // No dividers, no row-sized padding. A trigger is a LINE in a short list — six of
    // them under one caption — not a settings row you act on. `SettingsRow` gave each
    // one `p-4` and a hairline, which turned six lines into six panels and made the
    // card twice as tall to say the same thing.
    <div className="group flex flex-col gap-1 px-3 py-1.5">
      <div className="flex items-center gap-2.5">
        <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
        <span className="text-[15px] font-medium">{source.label}</span>
        {isEveryEvent && <EveryEventGlyphs />}
        {!source.enabled && <Badge variant="destructive">Disabled</Badge>}
        {/* A parent that fires this agent without declaring it is real
            behaviour the playbook does not describe — runtime Task
            delegation. Surfacing it is the whole point of carrying
            `declared` and `runs30d` on one row. */}
        {source.kind === 'agent' && !source.declared && (
          <Badge
            variant="secondary"
            title={`${source.from.name} has actually run this agent, but its playbook does not say it would — it delegated at runtime. Nothing here configures it, so nothing here can turn it off.`}
          >
            fires, but nothing declares it
          </Badge>
        )}
        {/* A playbook trigger has no per-trigger count to show — `agent_executions`
            does not record WHICH block fired a run — and an em dash in the corner of
            every row is a column of punctuation that answers nothing. The agent-level
            total is stated once, in the line above the list. */}
        {source.runs30d !== null && (
          <span className="text-muted-foreground shrink-0 text-xs tabular-nums">
            {source.runs30d} runs/30d
          </span>
        )}

        {/* The spacer, so the right-hand controls stay right-aligned whether or not
            this row has a count to show. */}
        <span className="ml-auto" />

        {/* An `agent` source is READ-ONLY here on purpose: removing it means
            editing the OTHER agent's body, which is not the document this page
            is showing. Link there instead of offering a delete that would edit
            something out of sight. */}
        {source.kind === 'agent' && (
          <a
            href={`/agent?agentId=${encodeURIComponent(source.from.agentId)}&tab=config`}
            className="text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
            aria-label={`Open ${source.from.name}`}
            title={`Configured in ${source.from.name} — open it to change this`}
          >
            <ExternalLink className="h-3.5 w-3.5" />
          </a>
        )}

        {onDelete && !confirming && (
          <button
            type="button"
            onClick={onArm}
            disabled={isMutating}
            aria-label={`Remove ${source.label}`}
            className={cn(
              'text-muted-foreground hover:text-destructive shrink-0 cursor-pointer rounded p-0.5 opacity-0 transition-opacity',
              'group-hover:opacity-100 focus-visible:opacity-100 disabled:opacity-30',
            )}
          >
            <Trash2 className="h-3.5 w-3.5" />
          </button>
        )}
      </div>

      {/* Confirm INLINE rather than on the click: a trigger is configuration
          other people's work depends on, and an undo does not exist for it. */}
      {onDelete && confirming && (
        <div className="flex items-center gap-2 pl-6 text-xs">
          <span className="text-muted-foreground">Remove this trigger from the playbook?</span>
          <Button
            type="button"
            size="sm"
            variant="destructive"
            className="h-6 cursor-pointer px-2 text-xs"
            onClick={onDelete}
            disabled={isMutating}
          >
            Remove
          </Button>
          <Button
            type="button"
            size="sm"
            variant="ghost"
            className="h-6 cursor-pointer px-2 text-xs"
            onClick={onCancel}
          >
            Cancel
          </Button>
        </div>
      )}

      {/* Row two: what THIS trigger tells the agent to do. Always under the
          trigger it belongs to, never behind a disclosure — an instruction you
          have to click to see is one nobody audits, and an empty field that shows
          itself is the thing that teaches the feature exists. */}
      {source.kind === 'playbook' && (
        <SourceInstructionField
          instructions={source.instructions}
          triggerLabel={source.label}
          {...(onUpdateInstructions ? { onCommit: onUpdateInstructions } : {})}
          {...(isMutating === undefined ? {} : { isMutating })}
        />
      )}

      {/* The URL used to be the whole story here, and a URL on its own is not a
          working webhook — see AgentWebhookPanel for the three questions that
          always follow it. Rendered only when the container supplied the panel, so
          this component still renders with no tRPC provider in scope. */}
      {webhook && renderWebhookPanel?.(webhook.webhookId, webhook.url)}
    </div>
  );
}

/**
 * The per-trigger instruction, edited from the agent side.
 *
 * These are the same bytes the playbook editor writes — the `<ref>` element's
 * body inside the trigger block — so the two surfaces are two views of one thing
 * rather than two settings that can disagree.
 *
 * COMMITTED ON BLUR, not on every keystroke. Each write is a locked read-patch-
 * recompile of PLAYBOOK.md behind an optimistic-concurrency guard, so a
 * per-keystroke mutation would be one document version per character and a
 * conflict against yourself the moment two of them overlapped. Escape abandons
 * the draft, which is the only undo a field that saves on blur can offer.
 *
 * The server's answer wins over a stale draft: every write returns the RE-RESOLVED
 * list, so when the saved value moves under us the box is reseeded from it.
 */
function SourceInstructionField({
  instructions,
  triggerLabel,
  onCommit,
  isMutating,
}: {
  instructions: string | null;
  /** Names the field for a screen reader — "the instruction for WHICH trigger". */
  triggerLabel: string;
  onCommit?: (instructions: string) => void;
  isMutating?: boolean;
}) {
  const saved = instructions ?? '';
  const [draft, setDraft] = useState(saved);
  /** The `saved` this draft was seeded from — see the reseed below. */
  const [seeded, setSeeded] = useState(saved);

  // Derive-during-render rather than an effect: an effect would paint the stale
  // draft for one frame after a save, which on a field that saves on blur reads
  // as the save having been reverted.
  if (seeded !== saved) {
    setSeeded(saved);
    setDraft(saved);
  }

  // Nothing to show and nothing to write. A read-only empty box with a
  // placeholder promises an edit that cannot happen here.
  if (!onCommit && !saved) return null;

  return (
    <TextareaField
      // NO visible label. "Instructions" as a title made every row read as a section
      // heading over a box, and stacked six deep that is six headings for one idea. The
      // sentence below the box does the naming instead — it has to exist anyway, because
      // "instructions" alone never said WHICH instructions or how they differ from the
      // agent's own. `aria-label` still names it per row for a screen reader, since
      // "obvious from context" is only true for the reader who can see the context.
      label={null}
      // The explanation IS the placeholder — no label above the box and no hint below it.
      // For an optional field the guidance is only needed while the box is empty, which is
      // exactly when a placeholder shows; once there is an instruction in it, the
      // instruction is the better description of itself. One row of the list, one control,
      // no scaffolding around it.
      //
      // The control still carries an `aria-label` naming its trigger: a placeholder is not
      // an accessible name (it disappears on input, and screen readers treat it as a hint),
      // so removing the label without that would leave six identical unnamed boxes.
      className="pl-6"
      rows={2}
      value={draft}
      readOnly={!onCommit}
      disabled={isMutating}
      aria-label={`Instructions for ${triggerLabel}`}
      placeholder="Trigger-specific instructions (optional): if you want special instructions for only this trigger to be given to the agent."
      onChange={(event) => setDraft(event.target.value)}
      onKeyDown={(event) => {
        if (event.key !== 'Escape') return;
        setDraft(saved);
        event.currentTarget.blur();
      }}
      onBlur={() => {
        // Compared trimmed because the server trims: a body of spaces and an
        // empty body are the same document, so saving one over the other would
        // bump the version to write nothing.
        if (!onCommit || draft.trim() === saved.trim()) return;
        onCommit(draft);
      }}
    />
  );
}