AgentSourcesPanel.tsx11.4 KBView on GitHub 'use client';
import {
AgentSourcesSection,
type AgentSourcesError,
type NewSourceRequest,
type PlaybookSource,
} from './AgentSourcesSection';
import type { AgentInvocationSource, PlaybookTriggerRef } from '@/modules/agents/types';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAgentPlaybookVersion } from '@/modules/agents/queries';
import { AgentWebhookPanel } from './AgentWebhookPanel';
import { useTRPC } from '@/providers/query-provider';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
/**
* The tRPC half of Config §1 — everything ./AgentSourcesSection.tsx deliberately
* does not know.
*
* Split so the section stays props-in and renders with no provider in scope: the
* interesting part of that component is what it puts on screen for a given list,
* and a component that reaches for a query client to be rendered at all cannot be
* tested on that. Everything below is wiring.
*
* Both mutations return the RE-RESOLVED list (the server re-reads the playbook
* after writing), so the cache is filled from the response rather than refetched:
* the answer in hand is already the one the next read would produce.
*/
export function AgentSourcesPanel({
agentId,
targetUserId,
aopId,
sources,
runCount30d,
isLoading,
adding,
onAddingChange,
className,
}: {
agentId: string;
/** An org admin/owner viewing a teammate's agent — see `AgentView`'s prop of the same name. */
targetUserId?: string;
/** The AOP whose status options seeded this playbook's `<stage>` elements. */
aopId?: string | null;
sources?: AgentInvocationSource[];
runCount30d?: number;
isLoading?: boolean;
/** Add-a-source is opened from the Config page's heading row, so it is controlled. */
adding?: boolean;
onAddingChange?: (adding: boolean) => void;
className?: string;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [error, setError] = useState<AgentSourcesError | null>(null);
/**
* The optimistic-concurrency token. Read separately from `agent.get` because a
* client re-reads it between edits without wanting the whole agent — and
* because every write hands back the next one, which is written straight into
* this cache entry below.
*
* Shared hook, not a local `useQuery`: AgentView fires the same one on
* mount so this is already answered by the time the Config tab renders.
*/
const { data: playbook } = useAgentPlaybookVersion(agentId, targetUserId);
/**
* The stages the add-form may offer, from the AOP's `status` options.
*
* That field is what SEEDS the playbook's stages — seed-playbook.ts writes one
* `<stage id="{value}" label="{label}">` per option — and `upsertPlaybookSource`
* matches a trigger's stage against those ids, case-insensitively, and refuses
* anything it cannot find. So this list is the set of stages a trigger can
* legally name, which is exactly what the dropdown should contain.
*
* A hand-authored playbook whose stage ids were edited away from the AOP's option
* values is the one case this can miss; the server still refuses clearly, and the
* old text box could not have helped anyway.
*/
const { data: aopData } = useQuery({
...trpc.aop.getAopById.queryOptions({ id: aopId ?? '', targetUserId }),
enabled: !!aopId,
});
const stages = useMemo(() => {
const options = aopData?.aop?.conversationFieldDefinitions?.status?.options;
if (!Array.isArray(options)) return undefined;
return options
.map((o) => ({ value: String(o.value ?? ''), label: o.label || String(o.value ?? '') }))
.filter((o) => o.value.length > 0);
}, [aopData]);
const onSettledWrite = (result: { sources: AgentInvocationSource[]; version: number }) => {
setError(null);
queryClient.setQueryData(
trpc.agent.getInvocationSources.queryKey({ agentId, targetUserId }),
result.sources,
);
queryClient.setQueryData(
trpc.agent.getPlaybookVersion.queryKey({ agentId, targetUserId }),
(prev) => (prev ? { ...prev, version: result.version } : prev),
);
// The agent list shows a "never fires" hint derived from this same list.
void queryClient.invalidateQueries({ queryKey=[redacted] });
};
const onWriteError = (err: unknown) => setError(toSourcesError(err));
const { mutate: addSource, isPending: isAdding } = useMutation(
trpc.agent.upsertPlaybookSource.mutationOptions({
onSuccess: (result) => {
onSettledWrite(result);
toast.success(result.changed ? 'Source added' : 'That source was already configured');
},
onError: onWriteError,
}),
);
/**
* The webhook's own mutation, because it is a different act: it mints the
* `playbook_webhooks` row AND patches the playbook, and deletes the row again if
* the patch fails. Routing it through `upsertPlaybookSource` would ask that
* procedure to write a block whose other half does not exist yet.
*/
const { mutate: createWebhook, isPending: isCreatingWebhook } = useMutation(
trpc.agent.createWebhookSource.mutationOptions({
onSuccess: (result) => {
onSettledWrite(result);
toast.success('Webhook created — copy the URL from the trigger');
},
onError: onWriteError,
}),
);
/**
* The per-trigger instruction — the same `upsertPlaybookSource` procedure, on
* purpose: writing a `<ref>` body and adding a `<ref>` are one act against one
* element, and the server decides which it is by whether the ref is there.
*
* A mutation of its OWN, though, only so the toast can say what happened. Two
* `useMutation`s over one route are two independent pending flags and one
* server behaviour, which is exactly the split wanted here.
*/
const { mutate: writeInstructions, isPending: isWritingInstructions } = useMutation(
trpc.agent.upsertPlaybookSource.mutationOptions({
onSuccess: (result) => {
onSettledWrite(result);
if (result.changed) toast.success('Instructions saved');
},
onError: onWriteError,
}),
);
const { mutate: removeSource, isPending: isRemoving } = useMutation(
trpc.agent.removePlaybookSource.mutationOptions({
onSuccess: (result) => {
onSettledWrite(result);
toast.success('Source removed');
},
onError: onWriteError,
}),
);
// Without a version there is nothing to write against — the optimistic guard is
// required, not optional — so the affordances are withheld rather than sending a
// guess. Compared inline rather than through a `canEdit` boolean so the narrowing
// reaches inside the callbacks.
const expectedVersion = playbook?.version;
return (
<AgentSourcesSection
sources={sources}
runCount30d={runCount30d}
isLoading={isLoading}
className={className}
isMutating={isAdding || isRemoving || isCreatingWebhook || isWritingInstructions}
error={error}
{...(stages ? { stages } : {})}
{...(adding === undefined ? {} : { adding })}
{...(onAddingChange ? { onAddingChange } : {})}
onAddSource={
expectedVersion === undefined
? undefined
: (request: NewSourceRequest) =>
request.kind === 'webhook'
? createWebhook({
agentId,
targetUserId,
expectedVersion,
...(request.label ? { label: request.label } : {}),
})
: addSource({ agentId, targetUserId, trigger: request.trigger, expectedVersion })
}
renderWebhookPanel={(webhookId, url) => (
<AgentWebhookPanel key=[redacted] webhookId={webhookId} url={url} />
)}
onDeleteSource={
expectedVersion === undefined
? undefined
: (source: PlaybookSource) =>
removeSource(removeInput(agentId, targetUserId, source, expectedVersion))
}
onUpdateInstructions={
expectedVersion === undefined
? undefined
: (source: PlaybookSource, instructions: string) =>
writeInstructions(
instructionsInput(agentId, targetUserId, source, instructions, expectedVersion),
)
}
/>
);
}
/**
* The remove payload, built as a NAMED value rather than inline.
*
* `sourceId` is the block's stable handle — the whole reason two same-shape
* triggers can be deleted independently. Absent (an older playbook, or a row
* whose id the PLAYBOOK.md editor round-trip dropped) the server matches on the
* trigger's shape, exactly as it did before ids existed, so the delete still
* works; it is just aimed at the first block of that shape.
*
* Named, and not an inline literal, so it reads as one payload beside
* `instructionsInput` below rather than as an argument list. Both procedures now
* declare `sourceId`, so it flows straight through.
*/
function removeInput(
agentId: string,
targetUserId: string | undefined,
source: PlaybookSource,
expectedVersion: number,
) {
return {
agentId,
targetUserId,
trigger: toWriteTrigger(source.trigger),
sourceId: source.sourceId,
expectedVersion,
};
}
/**
* The instruction payload — an upsert aimed at a block that is ALREADY THERE.
*
* `sourceId` matters more here than anywhere else: an add can settle for the
* first block of a matching shape, because if there isn't one it makes one. An
* instruction edit cannot — the user is looking at one row, and two blocks of the
* same shape (the same cron authored twice) are one address without the id. Where
* the row has none, the server still falls back to the shape, which is the
* pre-id behaviour and the best that can be done for a document with no handles.
*/
function instructionsInput(
agentId: string,
targetUserId: string | undefined,
source: PlaybookSource,
instructions: string,
expectedVersion: number,
) {
return {
agentId,
// Carried for the same reason `removeInput` carries it, and it was the one payload
// on this panel that did not: an instruction edit made while viewing a teammate's
// agent patched the ADMIN's playbook — a write to the wrong person's document,
// under a screen showing somebody else's.
targetUserId,
trigger: toWriteTrigger(source.trigger),
sourceId: source.sourceId,
instructions,
expectedVersion,
};
}
/**
* The read model's trigger minus the two fields the WRITE side never reads out of
* the playbook: a webhook's `url` and `lastFiredAt` come from `playbook_webhooks`.
* Mirrors `toWriteTrigger` in services/agent-workspace/delete-agent.ts.
*/
function toWriteTrigger(trigger: PlaybookTriggerRef) {
if (trigger.type !== 'webhook') return trigger;
const { type, scope, stage, webhookId } = trigger;
return { type, scope, ...(stage ? { stage } : {}), webhookId };
}
/**
* A version conflict is its own kind, because it is the only failure whose fix is
* "reload" rather than "change what you asked for". The router maps
* `PLAYBOOK_VERSION_CONFLICT` onto a tRPC CONFLICT, so the shape is checked
* before the message — a string match on prose would break the day the copy
* changes.
*/
function toSourcesError(err: unknown): AgentSourcesError {
const code = (err as { data?: { code?: string } } | null)?.data?.code;
if (code === 'CONFLICT') return { kind: 'conflict', message: 'The playbook changed.' };
return {
kind: 'error',
message: err instanceof Error ? err.message : 'Could not update the playbook',
};
}