WebhookTriggerPanel.tsx5.3 KBView on GitHub 'use client';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/providers/query-provider';
function curlExample(url: string): string {
return `curl -X POST '${url}' \\\n -H 'content-type: application/json' \\\n -d '{"hello":"world"}'`;
}
/**
* Presentational webhook panel — copyable POST URL, curl example, enable toggle,
* and rotate. Pure props so it can be unit-tested without tRPC/TipTap.
*/
export function WebhookTriggerPanelView({
url,
enabled,
lastTriggeredAt,
loading = false,
rotating = false,
onRotate,
onToggleEnabled,
}: {
url?: string;
enabled: boolean;
lastTriggeredAt?: string | Date | null;
loading?: boolean;
rotating?: boolean;
onRotate: () => void;
onToggleEnabled: (value: boolean) => void;
}) {
const [copied, setCopied] = useState(false);
if (!url) {
return (
<p className="text-sm text-muted-foreground">
{loading ? 'Generating webhook URL…' : 'No webhook URL yet.'}
</p>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Any <code>POST</code> to this URL triggers this agent. The request body is the only event
context (no conversation).
</p>
<div className="flex items-center gap-2">
<Input
readOnly
value={url}
data-testid="webhook-url"
className="h-8 font-mono text-sm"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-2 text-xs">{curlExample(url)}</pre>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={onToggleEnabled} aria-label="Enabled" />
{enabled ? 'Enabled' : 'Disabled'}
</label>
<Button type="button" size="sm" variant="ghost" onClick={onRotate} disabled={rotating}>
<RefreshCw className={cn('mr-1 h-3 w-3', rotating && 'animate-spin')} />
Rotate URL
</Button>
</div>
{lastTriggeredAt ? (
<p className="text-xs text-muted-foreground">
Last triggered {new Date(lastTriggeredAt).toLocaleString()}
</p>
) : null}
</div>
);
}
/**
* tRPC-backed container. Mints a webhook row on first render (when the block has
* no `webhookId` yet), stores the id back on the block via {@link onMinted}, and
* wires rotate/enable to the aop router.
*/
export function WebhookTriggerPanel({
aopId,
ownerUserId,
webhookId,
onMinted,
}: {
aopId: string | null;
/**
* The playbook's owner. Threaded from the node rather than read from the
* member picker: this panel renders inside playbook documents, which the
* `/brain` editor opens for any member without mounting a picker.
*/
ownerUserId?: string | null;
webhookId?: string;
onMinted: (webhookId: string) => void;
}) {
const trpc = useTRPC();
const targetUserId = ownerUserId ?? undefined;
const createMutation = useMutation(trpc.aop.createPlaybookWebhook.mutationOptions());
const rotateMutation = useMutation(trpc.aop.rotatePlaybookWebhookToken.mutationOptions());
const enableMutation = useMutation(trpc.aop.setPlaybookWebhookEnabled.mutationOptions());
// Mint exactly once when the block has no webhookId yet.
const mintedRef = useRef(false);
useEffect(() => {
if (webhookId || mintedRef.current || !aopId) return;
mintedRef.current = true;
createMutation
.mutateAsync({ aopId, scope: 'user', targetUserId })
.then((result) => onMinted(result.webhookId))
.catch(() => {
mintedRef.current = false;
});
// createMutation/onMinted are stable enough; mint is keyed on (aopId, webhookId).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aopId, webhookId]);
const webhookQuery = useQuery({
...trpc.aop.getPlaybookWebhook.queryOptions({ webhookId: webhookId ?? '', targetUserId }),
enabled: Boolean(webhookId),
});
const data = webhookQuery.data;
return (
<WebhookTriggerPanelView
url={data?.url}
enabled={data?.enabled ?? true}
lastTriggeredAt={data?.lastTriggeredAt ?? null}
loading={createMutation.isPending || webhookQuery.isLoading}
rotating={rotateMutation.isPending}
onRotate={() => {
if (!webhookId) return;
rotateMutation.mutateAsync({ webhookId, targetUserId }).then(() => webhookQuery.refetch());
}}
onToggleEnabled={(value) => {
if (!webhookId) return;
enableMutation
.mutateAsync({ webhookId, enabled: value, targetUserId })
.then(() => webhookQuery.refetch());
}}
/>
);
}