AgentWebhookPanel.tsx5.1 KBView on GitHub 'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Check, Copy, Loader2, Play } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
/**
* Everything you need to actually USE a webhook, on the trigger that owns it.
*
* A URL alone is not a working webhook. The questions that follow it are always the
* same three — what do I POST, does it reach the agent, and did the agent do
* anything — and until now all three were answered by leaving Cedar: copy the URL,
* open Postman, guess the body, fire it, then go hunting through Previous Runs to
* find out whether anything happened.
*
* So this panel answers them where the URL is:
*
* COPY URL — the endpoint.
* COPY cURL — a complete, runnable request. Paste into a terminal, or into
* Postman/Insomnia/Newman, all of which import a cURL command
* directly. That import is the integration; Cedar does not need to
* know those tools exist.
* TEST — `aop.testFirePlaybookWebhook`, which runs the REAL dispatch path
* (AOP resolution → section → execution) rather than a mock, and
* reports what came back.
*
* The most valuable thing it reports is the boring one: dispatch returning nothing.
* That almost always means no `<trigger type="webhook" id="…">` block references
* this row — the setup mistake that otherwise presents as a silent no-op, and the
* exact failure `agent.createWebhookSource` exists to make impossible.
*/
export function AgentWebhookPanel({
webhookId,
url,
className,
}: {
webhookId: string;
url: string | null;
className?: string;
}) {
const trpc = useTRPC();
const [copied, setCopied] = useState<'url' | 'curl' | null>(null);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
const { mutate: testFire, isPending } = useMutation(
trpc.aop.testFirePlaybookWebhook.mutationOptions({
onSuccess: (r) => {
// `note` is the server's own diagnosis of a no-op dispatch, and it is more
// specific than anything this component could infer. Prefer it verbatim.
setResult(
r.outcome
? { ok: true, text: 'Fired — the agent ran. Check Previous Runs for its output.' }
: { ok: false, text: r.note ?? 'Nothing ran.' },
);
},
onError: (err) =>
setResult({ ok: false, text: err instanceof Error ? err.message : 'Test failed' }),
}),
);
if (!url) {
// A row whose token could not be read. Say which half is missing rather than
// rendering a copy button over an empty string.
return (
<p className={cn('text-muted-foreground pl-6 text-xs', className)}>
This webhook exists but its URL could not be loaded.
</p>
);
}
const curl =
`curl -X POST '${url}' \\\n` +
` -H 'Content-Type: application/json' \\\n` +
` -d '{"hello":"world"}'`;
const copy = async (what: 'url' | 'curl') => {
try {
await navigator.clipboard.writeText(what === 'url' ? url : curl);
setCopied(what);
setTimeout(() => setCopied(null), 1500);
} catch {
toast.error('Could not copy — select it manually');
}
};
return (
<div className={cn('flex flex-col gap-1.5 pl-6', className)}>
<button
type="button"
onClick={() => void copy('url')}
className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-left font-mono text-xs"
title="Copy the endpoint"
>
{copied === 'url' ? (
<Check className="h-3 w-3 shrink-0 text-green-600" />
) : (
<Copy className="h-3 w-3 shrink-0" />
)}
<span className="truncate">{url}</span>
</button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => void copy('curl')}
>
{copied === 'curl' ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />}
Copy cURL
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 gap-1.5 px-2 text-xs"
disabled={isPending}
onClick={() => {
setResult(null);
testFire({ webhookId, payload: { hello: 'world' } });
}}
>
{isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
Send test request
</Button>
<span className="text-muted-foreground text-[11px]">
Paste the cURL into Postman, Insomnia or Newman to import it.
</span>
</div>
{result && (
<p
role="status"
className={cn(
'text-xs',
result.ok ? 'text-muted-foreground' : 'text-amber-700 dark:text-amber-400',
)}
>
{result.text}
</p>
)}
</div>
);
}