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