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