chat-messages: add hitl module with types, hooks, bundle, approval cards, and edit panel.

This commit is contained in:
CREDO23 2026-05-09 18:31:23 +02:00
parent d9ad9ca5cb
commit 9e451a5907
17 changed files with 1444 additions and 0 deletions

View file

@ -0,0 +1,191 @@
"use client";
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types";
import { isInterruptResult } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
/**
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
* backend signals these by setting ``context.permission === "doom_loop"``
* on the ``permission_ask`` interrupt.
*
* The card replaces the generic "approve/reject" framing with a
* "continue/stop" affordance that better matches the user's mental
* model: the agent is stuck repeating itself, not asking permission
* for a destructive action.
*/
function DoomLoopCardView({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const context = (interruptData.context ?? {}) as Record<string, unknown>;
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
const recentSignatures = Array.isArray(context.recent_signatures)
? (context.recent_signatures as string[])
: [];
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const argPreview = useMemo(() => {
if (!args || Object.keys(args).length === 0) return null;
try {
const json = JSON.stringify(args, null, 2);
return json.length > 600 ? `${json.slice(0, 600)}` : json;
} catch {
return null;
}
}, [args]);
const handleContinue = useCallback(() => {
if (phase !== "pending") return;
setProcessing();
onDecision({ type: "approve" });
}, [phase, setProcessing, onDecision]);
const handleStop = useCallback(() => {
if (phase !== "pending") return;
setRejected();
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
}, [phase, setRejected, onDecision]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (phase !== "pending") return;
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleStop();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [phase, handleStop]);
const isResolved = phase === "complete" || phase === "rejected";
return (
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
<OctagonAlert className="size-4" />
<AlertTitle className="flex items-center gap-2">
<span>
{phase === "rejected"
? "Stopped"
: phase === "processing"
? "Continuing…"
: phase === "complete"
? "Continued"
: "I might be stuck"}
</span>
{!isResolved && (
<Badge variant="outline" className="font-mono text-[10px]">
doom-loop
</Badge>
)}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
{phase === "processing" ? (
<TextShimmerLoader text="Resuming…" size="sm" />
) : phase === "rejected" ? (
<p className="text-xs">
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
</p>
) : phase === "complete" ? (
<p className="text-xs">
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
</p>
) : (
<p className="text-xs">
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
with similar arguments. Should I keep going or stop and rethink?
</p>
)}
{argPreview && phase === "pending" && (
<>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Last arguments
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
{argPreview}
</pre>
</div>
</>
)}
{recentSignatures.length > 0 && phase === "pending" && (
<details className="text-[11px] text-muted-foreground">
<summary className="cursor-pointer select-none">
Show repeated signatures ({recentSignatures.length})
</summary>
<ul className="mt-1 ml-4 list-disc">
{recentSignatures.map((sig) => (
<li key={sig} className="font-mono break-all">
{sig}
</li>
))}
</ul>
</details>
)}
{phase === "pending" && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
Stop and rethink
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button size="sm" variant="ghost" onClick={handleContinue}>
Continue anyway
</Button>
</div>
)}
</AlertDescription>
</Alert>
);
}
/**
* Discriminator: returns true iff the result is a ``permission_ask``
* interrupt with ``context.permission === "doom_loop"``. The fallback
* uses this BEFORE mounting an approval card to choose between
* ``DoomLoopApproval`` and ``GenericHitlApproval``.
*/
export function isDoomLoopInterrupt(result: unknown): boolean {
if (!isInterruptResult(result)) return false;
const ctx = (result.context ?? {}) as Record<string, unknown>;
return ctx.permission === "doom_loop";
}
/**
* Specialized doom-loop approval mounted by ``FallbackToolBody`` when
* ``isDoomLoopInterrupt(result)`` is true. Caller is responsible for
* the discrimination; this card receives a known ``InterruptResult``.
*/
export const DoomLoopApproval: HitlApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
return (
<DoomLoopCardView
toolName={toolName}
args={args}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};

View file

@ -0,0 +1,261 @@
"use client";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
function ParamEditor({
params,
onChange,
disabled,
}: {
params: Record<string, unknown>;
onChange: (updated: Record<string, unknown>) => void;
disabled: boolean;
}) {
const entries = Object.entries(params);
if (entries.length === 0) return null;
return (
<div className="space-y-2">
{entries.map(([key, value]) => {
const strValue = value == null ? "" : String(value);
const isLong = strValue.length > 120;
const fieldId = `hitl-param-${key}`;
return (
<div key={key} className="space-y-1">
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{key}
</label>
{isLong ? (
<Textarea
id={fieldId}
value={strValue}
disabled={disabled}
rows={3}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
) : (
<Input
id={fieldId}
value={strValue}
disabled={disabled}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
)}
</div>
);
})}
</div>
);
}
function GenericApprovalCardView({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
const [isEditing, setIsEditing] = useState(false);
const displayName = getToolDisplayName(toolName);
const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined;
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
const isMCPTool = mcpConnectorId != null;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const hasChanged = useMemo(() => {
return JSON.stringify(editedParams) !== JSON.stringify(args);
}, [editedParams, args]);
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
const isEdited = isEditing && hasChanged;
setProcessing();
onDecision({
type: isEdited ? "edit" : "approve",
edited_action: isEdited
? { name: interruptData.action_requests[0]?.name ?? toolName, args: editedParams }
: undefined,
});
}, [
phase,
setProcessing,
isEditing,
hasChanged,
onDecision,
interruptData,
toolName,
editedParams,
]);
const handleAlwaysAllow = useCallback(() => {
if (phase !== "pending" || !isMCPTool) return;
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error(
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && phase === "pending") {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove, phase]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? `${displayName} — Rejected`
: phase === "processing" || phase === "complete"
? `${displayName} — Approved`
: displayName}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Executing..." size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action completed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
{mcpServer && (
<p className="text-[10px] text-muted-foreground/70 mt-1">
via <span className="font-medium">{mcpServer}</span>
</p>
)}
</div>
{phase === "pending" && canEdit && !isEditing && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => setIsEditing(true)}
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
{toolDescription && phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3">
<p className="text-xs text-muted-foreground">{toolDescription}</p>
</div>
</>
)}
{Object.keys(args).length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
{phase === "pending" && isEditing ? (
<ParamEditor
params={editedParams}
onChange={setEditedParams}
disabled={phase !== "pending"}
/>
) : (
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all bg-muted/50 rounded-lg p-3">
{JSON.stringify(args, null, 2)}
</pre>
)}
</div>
</>
)}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
{isEditing && hasChanged ? "Approve with edits" : "Approve"}
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{isMCPTool && (
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
Always Allow
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
/**
* Default approval card mounted by ``FallbackToolBody`` for unknown HITL
* tools. Per-tool integrations may also import and compose this card on
* top of their own framing.
*
* Caller (the fallback) is responsible for the ``isInterruptResult``
* guard; this card receives a known ``InterruptResult`` and skips the
* defensive runtime check.
*/
export const GenericHitlApproval: HitlApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
return (
<GenericApprovalCardView
toolName={toolName}
args={args}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};

View file

@ -0,0 +1,2 @@
export { DoomLoopApproval, isDoomLoopInterrupt } from "./doom-loop-approval";
export { GenericHitlApproval } from "./generic-approval";

View file

@ -0,0 +1,157 @@
"use client";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import type { HitlDecision } from "../types";
export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void;
export interface HitlBundleAPI {
toolCallIds: readonly string[];
currentStep: number;
stagedCount: number;
isInBundle: (toolCallId: string) => boolean;
isCurrentStep: (toolCallId: string) => boolean;
getStaged: (toolCallId: string) => HitlDecision | undefined;
stage: (toolCallId: string, decision: HitlDecision) => void;
goToStep: (i: number) => void;
next: () => void;
prev: () => void;
submit: () => void;
}
const HitlBundleContext = createContext<HitlBundleAPI | null>(null);
const ToolCallIdContext = createContext<string | null>(null);
export function useHitlBundle(): HitlBundleAPI | null {
return useContext(HitlBundleContext);
}
export function useToolCallIdContext(): string | null {
return useContext(ToolCallIdContext);
}
export function ToolCallIdProvider({
toolCallId,
children,
}: {
toolCallId: string;
children: ReactNode;
}) {
return <ToolCallIdContext.Provider value={toolCallId}>{children}</ToolCallIdContext.Provider>;
}
interface HitlBundleProviderProps {
toolCallIds: readonly string[] | null;
onSubmit: BundleSubmit;
children: ReactNode;
}
/**
* Coordinates N pending HITL decisions into ONE ordered submission.
*
* Active only when ``toolCallIds`` has 2+ entries (parallel interrupts);
* single-card interrupts bypass the bundle entirely (``useHitlDecision``
* fires the ``hitl-decision`` window event directly).
*
* Pager UX: ``tool-call-item.tsx`` reads ``isInBundle`` + ``isCurrentStep``
* to render only the current-step card; ``timeline.tsx`` mounts
* ``<PagerChrome />`` once when this Provider is active. Submission is
* user-initiated via the pager's "Submit decisions" button (calls
* ``submit()``); not auto.
*/
export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) {
const active = toolCallIds !== null && toolCallIds.length >= 2;
const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]);
const bundleKey = ids.join("|");
const [prevBundleKey, setPrevBundleKey] = useState(bundleKey);
const [staged, setStaged] = useState<Map<string, HitlDecision>>(() => new Map());
const [currentStep, setCurrentStep] = useState(0);
if (bundleKey !== prevBundleKey) {
setPrevBundleKey(bundleKey);
setStaged(new Map());
setCurrentStep(0);
}
const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]);
const isCurrentStep = useCallback(
(tcId: string) => active === true && ids[currentStep] === tcId,
[active, ids, currentStep]
);
const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]);
const stage = useCallback(
(tcId: string, decision: HitlDecision) => {
if (!active || !ids.includes(tcId)) return;
setStaged((prev) => {
const next = new Map(prev);
next.set(tcId, decision);
return next;
});
window.dispatchEvent(
new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } })
);
const idx = ids.indexOf(tcId);
if (idx >= 0 && idx < ids.length - 1) {
setCurrentStep(idx + 1);
}
},
[active, ids]
);
const goToStep = useCallback(
(i: number) => {
if (i < 0 || i >= ids.length) return;
setCurrentStep(i);
},
[ids.length]
);
const next = useCallback(() => {
setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1)));
}, [ids.length]);
const prev = useCallback(() => {
setCurrentStep((s) => Math.max(s - 1, 0));
}, []);
const submit = useCallback(() => {
if (!active) return;
if (staged.size !== ids.length) return;
const ordered: HitlDecision[] = [];
for (const tcId of ids) {
const d = staged.get(tcId);
if (!d) return;
ordered.push(d);
}
onSubmit(ordered);
}, [active, ids, staged, onSubmit]);
const value = useMemo<HitlBundleAPI | null>(() => {
if (!active) return null;
return {
toolCallIds: ids,
currentStep,
stagedCount: staged.size,
isInBundle,
isCurrentStep,
getStaged,
stage,
goToStep,
next,
prev,
submit,
};
}, [
active,
ids,
currentStep,
staged,
isInBundle,
isCurrentStep,
getStaged,
stage,
goToStep,
next,
prev,
submit,
]);
return <HitlBundleContext.Provider value={value}>{children}</HitlBundleContext.Provider>;
}

View file

@ -0,0 +1,8 @@
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
export {
HitlBundleProvider,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle-context";
export { PagerChrome } from "./pager-chrome";

View file

@ -0,0 +1,65 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useHitlBundle } from "./bundle-context";
/**
* Prev/next nav and Submit for the current step of an active HITL bundle.
* Submission is gated on every action_request having a staged decision.
*
* Mounted ONCE by ``timeline.tsx`` when the bundle is active. Does NOT
* wrap individual cards. Reads bundle state via ``useHitlBundle()``;
* renders nothing when no bundle is active.
*/
export function PagerChrome() {
const bundle = useHitlBundle();
if (!bundle) return null;
const total = bundle.toolCallIds.length;
const step = bundle.currentStep;
const allStaged = bundle.stagedCount === total;
return (
<div className="mt-3 flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2 text-sm">
<Button
type="button"
size="sm"
variant="outline"
onClick={bundle.prev}
disabled={step === 0}
aria-label="Previous approval"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span className="font-medium tabular-nums">
{step + 1} / {total}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{bundle.stagedCount} of {total} decided
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={bundle.next}
disabled={step >= total - 1}
aria-label="Next approval"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<div className="ml-auto">
<Button
type="button"
size="sm"
onClick={bundle.submit}
disabled={!allStaged}
title={allStaged ? "Submit decisions" : "Decide every action first"}
>
Submit decisions
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
export interface ExtraField {
label: string;
key: string;
value: string;
type: "text" | "email" | "emails" | "datetime-local" | "textarea";
}
interface HitlEditPanelState {
isOpen: boolean;
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave:
| ((title: string, content: string, extraFieldValues?: Record<string, string>) => void)
| null;
onClose: (() => void) | null;
}
const initialState: HitlEditPanelState = {
isOpen: false,
title: "",
content: "",
toolName: "",
contentFormat: undefined,
extraFields: undefined,
onSave: null,
onClose: null,
};
export const hitlEditPanelAtom = atom<HitlEditPanelState>(initialState);
const preHitlCollapsedAtom = atom<boolean | null>(null);
export const openHitlEditPanelAtom = atom(
null,
(
get,
set,
payload: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
}
) => {
if (!get(hitlEditPanelAtom).isOpen) {
set(preHitlCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(hitlEditPanelAtom, {
isOpen: true,
title: payload.title,
content: payload.content,
toolName: payload.toolName,
contentFormat: payload.contentFormat,
extraFields: payload.extraFields,
onSave: payload.onSave,
onClose: payload.onClose ?? null,
});
set(rightPanelTabAtom, "hitl-edit");
set(rightPanelCollapsedAtom, false);
}
);
export const closeHitlEditPanelAtom = atom(null, (get, set) => {
const current = get(hitlEditPanelAtom);
current.onClose?.();
set(hitlEditPanelAtom, initialState);
set(rightPanelTabAtom, "sources");
const prev = get(preHitlCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preHitlCollapsedAtom, null);
}
});

View file

@ -0,0 +1,203 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Skeleton } from "@/components/ui/skeleton";
import { useMediaQuery } from "@/hooks/use-media-query";
import { closeHitlEditPanelAtom, type ExtraField, hitlEditPanelAtom } from "./edit-panel.atom";
import { ExtraFieldsSection } from "./fields";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
/**
* The actual editable form. Controlled by atom data via the
* Desktop/Mobile shells below; isolated from layout so the same form
* renders identically in either container.
*/
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
contentFormat,
extraFields,
onSave,
onClose,
showCloseButton = true,
}: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const contentRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleContentChange = useCallback((content: string) => {
contentRef.current = content;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, contentRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
<div className="flex items-center gap-2 px-4 py-2 shrink-0 border-b">
<input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Untitled"
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Page title"
/>
{onClose && showCloseButton && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close panel</span>
</Button>
)}
</div>
{extraFields && extraFields.length > 0 && (
<ExtraFieldsSection
fields={extraFields}
values={extraFieldValues}
onFieldChange={handleExtraFieldChange}
/>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
{...(contentFormat === "html"
? { html: initialContent, onHtmlChange: handleContentChange }
: { markdown: initialContent, onMarkdownChange: handleContentChange })}
readOnly={false}
preset="full"
placeholder="Start writing..."
editorVariant="default"
defaultEditing
onSave={handleSave}
hasUnsavedChanges
isSaving={isSaving}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
</div>
</>
);
}
function DesktopHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.isOpen || !panelState.onSave) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
</div>
);
}
function MobileHitlEditDrawer() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.onSave) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}
/>
</div>
</DrawerContent>
</Drawer>
);
}
/**
* Entry point mounted by the right-panel layout. Renders the desktop
* panel on lg+ and the mobile drawer below; both share state via the
* ``hitlEditPanelAtom``.
*/
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
/**
* Entry point mounted by chat pages so the mobile drawer can render
* outside the desktop right-panel container.
*/
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -0,0 +1,112 @@
"use client";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
function parseDateTimeValue(value: string): { date: Date | undefined; time: string } {
if (!value) return { date: undefined, time: "09:00" };
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" };
return {
date: d,
time: format(d, "HH:mm"),
};
} catch {
return { date: undefined, time: "09:00" };
}
}
function buildLocalDateTimeString(date: Date | undefined, time: string): string {
if (!date) return "";
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours ?? 9, minutes ?? 0, 0, 0);
const y = combined.getFullYear();
const m = String(combined.getMonth() + 1).padStart(2, "0");
const d = String(combined.getDate()).padStart(2, "0");
const h = String(combined.getHours()).padStart(2, "0");
const min = String(combined.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:00`;
}
/**
* Calendar popover + 24h time input. Emits a local ISO-like string
* (``YYYY-MM-DDThh:mm:00``) on every change. Value is parsed back into
* date + time on every render so the picker stays in sync with
* controlled props.
*/
export function DateTimePickerField({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const parsed = useMemo(() => parseDateTimeValue(value), [value]);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed.date);
const [time, setTime] = useState(parsed.time);
const [open, setOpen] = useState(false);
const handleDateSelect = useCallback(
(day: Date | undefined) => {
setSelectedDate(day);
onChange(buildLocalDateTimeString(day, time));
setOpen(false);
},
[time, onChange]
);
const handleTimeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTime(newTime);
onChange(buildLocalDateTimeString(selectedDate, newTime));
},
[selectedDate, onChange]
);
const displayLabel = selectedDate
? `${format(selectedDate, "MMM d, yyyy")} at ${time}`
: "Pick date & time";
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
className="flex-1 flex items-center gap-2 h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring"
>
<CalendarIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className={selectedDate ? "text-foreground" : "text-muted-foreground"}>
{displayLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
defaultMonth={selectedDate}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { TagInput, type Tag as TagType } from "emblor";
import { useCallback, useEffect, useRef, useState } from "react";
function parseEmailsToTags(value: string): TagType[] {
if (!value.trim()) return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((email, i) => ({ id: `${Date.now()}-${i}`, text: email }));
}
function tagsToEmailString(tags: TagType[]): string {
return tags.map((t) => t.text).join(", ");
}
/**
* Comma-separated email field rendered as a tag input. Internal tag
* state is the source of truth; comma-string is propagated to the
* caller via ``onChange`` whenever tags change (skipping the initial
* mount to avoid spurious updates).
*/
export function EmailsTagField({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [tags, setTags] = useState<TagType[]>(() => parseEmailsToTags(value));
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const isInitialMount = useRef(true);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
return [...prev, newTag];
});
}, []);
return (
<TagInput
id={id}
tags={tags}
setTags={handleSetTags}
placeholder={placeholder ?? "Add email"}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border border-input rounded-md bg-transparent shadow-xs transition-[color,box-shadow] outline-none focus-within:border-ring p-1 gap-1",
input:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground placeholder:text-muted-foreground bg-transparent text-sm md:text-sm",
tag: {
body: "h-7 relative bg-accent dark:bg-muted/60 border-0 hover:bg-accent/80 dark:hover:bg-muted rounded-md font-medium text-xs text-foreground/80 ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-md flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-foreground hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ExtraField } from "../edit-panel.atom";
import { DateTimePickerField } from "./calendar-field";
import { EmailsTagField } from "./email-tags-field";
/**
* Renders ``ExtraField[]`` as a labelled vertical stack. Picks the
* input control from ``field.type``; unknown types fall back to a
* plain ``<Input type={field.type} />`` (covers "text" and "email").
*
* Pure presentational component owns no state, just maps values to
* controls and propagates changes through ``onFieldChange(key, value)``.
*/
export function ExtraFieldsSection({
fields,
values,
onFieldChange,
}: {
fields: ExtraField[];
values: Record<string, string>;
onFieldChange: (key: string, value: string) => void;
}) {
if (fields.length === 0) return null;
return (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{fields.map((field) => {
const fieldId = `extra-field-${field.key}`;
const currentValue = values[field.key] ?? "";
return (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={fieldId}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={fieldId}
type={field.type}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { DateTimePickerField } from "./calendar-field";
export { EmailsTagField } from "./email-tags-field";
export { ExtraFieldsSection } from "./extra-fields";

View file

@ -0,0 +1,7 @@
export { HitlEditPanel, HitlEditPanelContent, MobileHitlEditPanel } from "./edit-panel";
export type { ExtraField } from "./edit-panel.atom";
export {
closeHitlEditPanelAtom,
hitlEditPanelAtom,
openHitlEditPanelAtom,
} from "./edit-panel.atom";

View file

@ -0,0 +1,31 @@
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
export {
type BundleSubmit,
type HitlBundleAPI,
HitlBundleProvider,
PagerChrome,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle";
export {
closeHitlEditPanelAtom,
type ExtraField,
HitlEditPanel,
HitlEditPanelContent,
hitlEditPanelAtom,
MobileHitlEditPanel,
openHitlEditPanelAtom,
} from "./edit-panel";
export type {
HitlApprovalCard,
HitlApprovalCardProps,
HitlDecision,
HitlPhase,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";
export { useHitlPhase } from "./use-hitl-phase";

View file

@ -0,0 +1,51 @@
import type { ReactNode } from "react";
export interface InterruptActionRequest {
name: string;
args: Record<string, unknown>;
}
export interface InterruptReviewConfig {
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: InterruptActionRequest[];
review_configs: InterruptReviewConfig[];
interrupt_type?: string;
context?: C;
message?: string;
}
export function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
export interface HitlDecision {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: {
name: string;
args: Record<string, unknown>;
};
}
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
export interface HitlApprovalCardProps {
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
result: InterruptResult;
}
export type HitlApprovalCard = (props: HitlApprovalCardProps) => ReactNode;

View file

@ -0,0 +1,45 @@
import { useCallback } from "react";
import { useHitlBundle, useToolCallIdContext } from "./bundle/bundle-context";
import type { HitlDecision } from "./types";
/**
* Dispatches a HITL decision from inside an approval card.
*
* Behavior:
* - **Bundle active** (N2 parallel interrupts) AND this card's
* ``toolCallId`` is in the bundle: stage the (single) decision
* against this ``toolCallId`` so the bundle can submit one ordered
* N-payload when every card has decided. Multi-decision dispatches
* in this path are a programming error: only ``decisions[0]`` is
* staged; a dev warning fires for the rest.
* - **Otherwise (N=1 or no bundle):** dispatch the ``hitl-decision``
* window event directly with the full ``decisions`` array. The host
* page's listener calls ``runtime.resume`` with the same array.
*
* Cards always call ``dispatch([decision])`` and don't need to know
* which path they're on.
*/
export function useHitlDecision() {
const bundle = useHitlBundle();
const toolCallId = useToolCallIdContext();
const dispatch = useCallback(
(decisions: HitlDecision[]) => {
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
console.warn(
"[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s",
decisions.length,
toolCallId
);
}
bundle.stage(toolCallId, decisions[0]);
return;
}
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
},
[bundle, toolCallId]
);
return { dispatch };
}

View file

@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import type { HitlPhase } from "./types";
interface HitlInterruptLike {
__decided__?: string | null;
__completed__?: boolean;
}
const MINIMUM_SHIMMER_MS = 500;
const FALLBACK_TIMEOUT_MS = 30_000;
/**
* Local UI state machine for a HITL approval card.
*
* Phase transitions:
* pending user has not yet decided (show approve/edit/reject buttons)
* processing user clicked; awaiting backend confirmation (shimmer)
* complete backend acknowledged via __completed__ (or fallback timeout)
* rejected user explicitly rejected (terminal state, no backend wait)
*
* Initial phase is derived from the current ``__decided__`` /
* ``__completed__`` markers on the result, so cards rehydrate
* correctly from persisted history.
*
* NOT shared across cards. Each approval card calls ``useHitlPhase``
* once with its own interrupt result.
*/
export function useHitlPhase(interruptData: HitlInterruptLike): {
phase: HitlPhase;
setProcessing: () => void;
setRejected: () => void;
} {
const [phase, setPhase] = useState<HitlPhase>(() => {
if (interruptData.__decided__ === "reject") return "rejected";
if (interruptData.__decided__) return "complete";
return "pending";
});
const shimmerStartRef = useRef<number | null>(null);
useEffect(() => {
if (phase !== "processing") return;
if (!interruptData.__completed__) return;
const elapsed = shimmerStartRef.current ? Date.now() - shimmerStartRef.current : Infinity;
const remaining = Math.max(0, MINIMUM_SHIMMER_MS - elapsed);
const timer = setTimeout(() => setPhase("complete"), remaining);
return () => clearTimeout(timer);
}, [phase, interruptData.__completed__]);
useEffect(() => {
if (phase !== "processing") return;
const fallback = setTimeout(() => setPhase("complete"), FALLBACK_TIMEOUT_MS);
return () => clearTimeout(fallback);
}, [phase]);
return {
phase,
setProcessing: () => {
shimmerStartRef.current = Date.now();
setPhase("processing");
},
setRejected: () => setPhase("rejected"),
};
}