mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 09:42:40 +02:00
chat-messages: add hitl module with types, hooks, bundle, approval cards, and edit panel.
This commit is contained in:
parent
d9ad9ca5cb
commit
9e451a5907
17 changed files with 1444 additions and 0 deletions
|
|
@ -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])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { DoomLoopApproval, isDoomLoopInterrupt } from "./doom-loop-approval";
|
||||||
|
export { GenericHitlApproval } from "./generic-approval";
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
|
||||||
|
export {
|
||||||
|
HitlBundleProvider,
|
||||||
|
ToolCallIdProvider,
|
||||||
|
useHitlBundle,
|
||||||
|
useToolCallIdContext,
|
||||||
|
} from "./bundle-context";
|
||||||
|
export { PagerChrome } from "./pager-chrome";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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 />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { DateTimePickerField } from "./calendar-field";
|
||||||
|
export { EmailsTagField } from "./email-tags-field";
|
||||||
|
export { ExtraFieldsSection } from "./extra-fields";
|
||||||
|
|
@ -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";
|
||||||
31
surfsense_web/features/chat-messages/hitl/index.ts
Normal file
31
surfsense_web/features/chat-messages/hitl/index.ts
Normal 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";
|
||||||
51
surfsense_web/features/chat-messages/hitl/types.ts
Normal file
51
surfsense_web/features/chat-messages/hitl/types.ts
Normal 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;
|
||||||
|
|
@ -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** (N≥2 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 };
|
||||||
|
}
|
||||||
66
surfsense_web/features/chat-messages/hitl/use-hitl-phase.ts
Normal file
66
surfsense_web/features/chat-messages/hitl/use-hitl-phase.ts
Normal 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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue