chat: drop legacy thinking-steps, tool-fallback, hitl modules, and span-indent helper.

This commit is contained in:
CREDO23 2026-05-09 18:35:52 +02:00
parent 9c5a178468
commit ba0e1e70a0
14 changed files with 0 additions and 1942 deletions

View file

@ -1,82 +0,0 @@
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

@ -1,433 +0,0 @@
import {
makeAssistantDataUI,
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TOOLS_BY_NAME, TOOLS_FALLBACK } from "@/components/assistant-ui/assistant-message";
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { HitlRenderTargetProvider, isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
export interface ThinkingStep {
id: string;
title: string;
items: string[];
status: "pending" | "in_progress" | "completed";
/**
* Optional relay metadata forwarded from ``data-thinking-step`` SSE
* (e.g. ``spanId`` set by ``AgentEventRelayState.span_metadata_if_active``).
* Steps under an open delegating ``task`` carry ``metadata.spanId`` and are
* grouped under the preceding parent (``task`` step) as indented children.
*/
metadata?: Record<string, unknown>;
}
/**
* Per-step info joined from the assistant message ``tool-call`` parts via
* the shared ``metadata.thinkingStepId`` correlation
* (set on the server in ``AgentEventRelayState.tool_activity_metadata``).
*
* Carries enough of the part to:
* - identify the opening ``task`` step and substitute the subagent display
* name on the parent header (uses ``toolName`` and ``args``);
* - render the matching tool component inline under the step row when the
* card's result is an HITL interrupt (uses ``toolCallId``, ``argsText``,
* ``result``, ``langchainToolCallId``).
*/
interface StepToolInfo {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
}
export type ThinkingStepToolInfoMap = ReadonlyMap<string, StepToolInfo>;
/**
* Build ``thinkingStepId StepToolInfo`` from message content. Used to
* - identify the opening ``task`` step (parent header, never indents) without
* relying on the human-readable title;
* - render the parent's display title from ``args.subagent_type`` instead of
* the generic "Task" copy;
* - mount the matching tool-call card inline under a step row when the
* result is an HITL interrupt (see ``TimelineHitlCard``).
*/
export function buildThinkingStepToolInfo(
content: readonly unknown[] | undefined
): ThinkingStepToolInfoMap {
const m = new Map<string, StepToolInfo>();
if (!content) return m;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const o = part as {
type?: string;
toolCallId?: string;
toolName?: string;
args?: Record<string, unknown>;
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
};
if (o.type !== "tool-call" || !o.toolName || !o.toolCallId) continue;
const tid = o.metadata?.thinkingStepId;
if (typeof tid === "string" && tid.trim().length > 0) {
m.set(tid, {
toolCallId: o.toolCallId,
toolName: o.toolName,
args: o.args ?? {},
argsText: o.argsText,
result: o.result,
langchainToolCallId: o.langchainToolCallId,
});
}
}
return m;
}
function asNonEmptyString(v: unknown): string | undefined {
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
}
function titleCaseSubagent(raw: string): string {
// "notion" → "Notion", "doc_research" → "Doc Research".
return raw
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
/**
* Display title for a step. For the opening ``task`` step we substitute the
* subagent type from the matching tool-call args (e.g. ``"Notion"`` instead of
* the generic ``"Task"``). Falls back to the step's own title if the tool-call
* hasn't streamed in yet.
*/
function resolveDisplayTitle(step: ThinkingStep, info: StepToolInfo | undefined): string {
if (info?.toolName === "task") {
const subagent = asNonEmptyString(info.args?.subagent_type);
if (subagent) return titleCaseSubagent(subagent);
}
return step.title;
}
function isDelegatedChild(step: ThinkingStep, info: StepToolInfo | undefined): boolean {
const sid = asNonEmptyString(step.metadata?.spanId);
if (!sid) return false;
// The opening ``task`` step also carries ``spanId`` (it owns the span) but
// must render as the parent header. Prefer the joined ``toolName`` (set by
// ``buildThinkingStepToolInfo`` from ``tool-call.metadata.thinkingStepId``).
// Fall back to the title heuristic when no tool-call is matched — happens
// for messages persisted before ``thinkingStepId`` shipped, and briefly
// during streaming if the ``tool-input-start`` frame hasn't been processed
// yet for some reason.
if (info) return info.toolName !== "task";
return step.title !== "Task";
}
interface StepGroup {
parent: ThinkingStep;
children: ThinkingStep[];
}
/**
* Group consecutive delegated child steps under the preceding parent step.
* If the very first step is a child (no parent yet seen), it's promoted to a
* parent so it still renders defensive only, real flows always start with a
* parent step.
*/
const EMPTY_STEP_TOOL_INFO: ThinkingStepToolInfoMap = new Map();
function groupSteps(
steps: readonly ThinkingStep[],
stepToolInfo: ThinkingStepToolInfoMap
): StepGroup[] {
const groups: StepGroup[] = [];
for (const step of steps) {
if (isDelegatedChild(step, stepToolInfo.get(step.id)) && groups.length > 0) {
groups[groups.length - 1].children.push(step);
} else {
groups.push({ parent: step, children: [] });
}
}
return groups;
}
const StepBody: FC<{
step: ThinkingStep;
status: "pending" | "in_progress" | "completed";
displayTitle: string;
}> = ({ step, status, displayTitle }) => (
<div className="min-w-0">
<div
className={cn(
"text-sm leading-5",
status === "in_progress" && "text-foreground font-medium",
status === "completed" && "text-muted-foreground",
status === "pending" && "text-muted-foreground/60"
)}
>
{displayTitle}
</div>
{step.items && step.items.length > 0 && (
<div className="mt-1 space-y-0.5">
{step.items.map((item) => (
<ChainOfThoughtItem key={`${step.id}-${item}`} className="text-xs">
{item}
</ChainOfThoughtItem>
))}
</div>
)}
</div>
);
/**
* Mount the same tool-call UI used in the message body, but inside the
* chain-of-thought timeline. The body copy returns ``null`` (see
* ``withHitlInTimeline`` in ``lib/hitl/render-target``), so the card
* effectively moves from the body to the timeline for the lifetime of the
* interrupt (pending processing complete / rejected).
*
* ``metadata`` is intentionally omitted from the props we forward the
* step row already provides any indentation it needs, so we don't want
* ``withDelegationSpanIndent`` to add a second indent + border on top.
*
* ``status`` is a placeholder (HITL UIs read only ``args`` + ``result``)
* so we don't need to mirror assistant-ui's runtime status object here.
*/
const TimelineHitlCard: FC<{ info: StepToolInfo }> = ({ info }) => {
const Comp =
(TOOLS_BY_NAME as Record<string, ToolCallMessagePartComponent | undefined>)[info.toolName] ??
TOOLS_FALLBACK;
const props = {
toolCallId: info.toolCallId,
toolName: info.toolName,
args: info.args,
argsText: info.argsText,
result: info.result,
langchainToolCallId: info.langchainToolCallId,
status: { type: "complete" } as const,
};
return (
<HitlRenderTargetProvider value="timeline">
{/* biome-ignore lint/suspicious/noExplicitAny: ToolCallMessagePartProps requires
runtime-only fields (addResult, resume, MessagePartState) we don't have when
re-rendering manually; HITL components only read args + result. */}
<Comp {...(props as any)} />
</HitlRenderTargetProvider>
);
};
function hitlInterruptInfo(info: StepToolInfo | undefined): StepToolInfo | undefined {
return info && isInterruptResult(info.result) ? info : undefined;
}
/**
* Chain of thought display component - single collapsible dropdown design.
*
* ``stepToolInfo`` joins each step (by ``thinkingStepId``) to its ``tool-call``
* part so we can:
* - replace the generic ``"Task"`` title with the real subagent name
* (``args.subagent_type``) on the parent header;
* - decide parent-vs-child purely from the matched ``toolName`` instead of
* relying on the displayed title.
*/
export const ThinkingStepsDisplay: FC<{
steps: ThinkingStep[];
isThreadRunning?: boolean;
stepToolInfo?: ThinkingStepToolInfoMap;
}> = ({ steps, isThreadRunning = true, stepToolInfo }) => {
const toolInfo = stepToolInfo ?? EMPTY_STEP_TOOL_INFO;
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed";
}
return step.status;
},
[isThreadRunning]
);
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const inProgressDisplayTitle = inProgressStep
? resolveDisplayTitle(inProgressStep, toolInfo.get(inProgressStep.id))
: undefined;
const allCompleted =
steps.length > 0 &&
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allCompleted) {
setIsOpen(false);
}
}, [allCompleted, isProcessing]);
const groups = useMemo(() => groupSteps(steps, toolInfo), [steps, toolInfo]);
if (steps.length === 0) return null;
const getHeaderText = () => {
if (allCompleted) {
return "Reviewed";
}
if (inProgressDisplayTitle) {
return inProgressDisplayTitle;
}
if (isProcessing) {
return "Processing";
}
return "Reviewed";
};
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{isProcessing ? (
<TextShimmerLoader text={getHeaderText()} size="sm" />
) : (
<span>{getHeaderText()}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-3 pl-1">
{groups.map((group, groupIndex) => {
const isLastGroup = groupIndex === groups.length - 1;
const parentStatus = getEffectiveStatus(group.parent);
const parentInfo = toolInfo.get(group.parent.id);
const parentTitle = resolveDisplayTitle(group.parent, parentInfo);
const hasChildren = group.children.length > 0;
// Parent dots are connected by a vertical line that runs through
// any indented children (their column has no dot, so the line
// passes cleanly behind them) and overshoots by ~15px to reach
// the next group's dot center (top-[15px]).
const showParentLine = !isLastGroup;
return (
<div key={group.parent.id} className="relative flex gap-3">
<div className="relative flex flex-col items-center w-2 self-stretch">
{showParentLine && (
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
)}
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
{parentStatus === "in_progress" ? (
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : (
<span className="size-2 rounded-full bg-muted-foreground/30" />
)}
</div>
</div>
<div className="flex-1 min-w-0 pb-4">
<StepBody
step={group.parent}
status={parentStatus}
displayTitle={parentTitle}
/>
{(() => {
const hitl = hitlInterruptInfo(parentInfo);
return hitl ? <TimelineHitlCard info={hitl} /> : null;
})()}
{hasChildren && (
<div className="mt-2 ml-3 space-y-2">
{group.children.map((child) => {
const childInfo = toolInfo.get(child.id);
const childHitl = hitlInterruptInfo(childInfo);
return (
<div key={child.id}>
<StepBody
step={child}
status={getEffectiveStatus(child)}
displayTitle={resolveDisplayTitle(child, childInfo)}
/>
{childHitl && <TimelineHitlCard info={childHitl} />}
</div>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
/**
* assistant-ui data UI component that renders thinking steps from message content.
* Registered globally via makeAssistantDataUI renders inside MessagePrimitive.Parts
* at the position of the data part in the content array.
*/
function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const content = useAuiState(({ message }) => message?.content);
const stepToolInfo = useMemo(
() => buildThinkingStepToolInfo(Array.isArray(content) ? content : undefined),
[content]
);
const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? [];
if (steps.length === 0) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<ThinkingStepsDisplay
steps={steps}
isThreadRunning={isMessageStreaming}
stepToolInfo={stepToolInfo}
/>
</div>
);
}
export const ThinkingStepsDataUI = makeAssistantDataUI({
name: "thinking-steps",
render: ThinkingStepsDataRenderer,
});

View file

@ -1,532 +0,0 @@
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
import {
DoomLoopApprovalToolUI,
isDoomLoopInterrupt,
} from "@/components/tool-ui/doom-loop-approval";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import {
DELEGATION_SPAN_INDENT_CLASS,
shouldIndentToolCallForDelegationSpan,
} from "@/lib/chat/delegation-span-indent";
import { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
/**
* Inline Revert button rendered on a tool card when the matching
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
*
* Reads from the unified ``useAgentActionsQuery`` cache the SAME
* react-query cache the agent-actions sheet consumes. SSE events
* (``data-action-log`` / ``data-action-log-updated``) and
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
* cache via ``setQueryData`` helpers, so the card and the sheet stay
* in lockstep on every code path: page reload, navigation, live
* stream, post-stream reversibility flip, and explicit revert clicks.
*
* Match key (in priority order):
* 1. ``a.tool_call_id === toolCallId`` direct hit when the model
* streamed ``tool_call_chunks`` so the card id matches the LangChain id.
* 2. ``a.tool_call_id === langchainToolCallId`` synthetic card id is
* ``call_<run_id>`` and the LangChain id is backfilled by
* ``tool-output-available``.
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` fallback
* for cards whose synthetic id is ``call_<run_id>`` AND whose
* ``langchainToolCallId`` never got backfilled (provider emitted
* the tool_call as a single payload with no chunks AND streaming
* pre-dated the ``tool-output-available langchainToolCallId``
* backfill, e.g. older threads). Reads the parent message's
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
* match position-by-tool-name within the turn against the
* action_log rows the server returned in ``created_at`` order.
*/
function ToolCardRevertButton({
toolCallId,
toolName,
langchainToolCallId,
}: {
toolCallId: string;
toolName: string;
langchainToolCallId?: string;
}) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const queryClient = useQueryClient();
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
// Parent message metadata, read via the narrowest possible
// selectors so this card doesn't re-render on every text-delta of
// every other part in the same message during streaming.
//
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
// returned slice's identity changes. Returning ``message?.content``
// (an array) would re-render on every token because the runtime
// rebuilds the parts array. Returning a PRIMITIVE (the position
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
// when the position hasn't actually moved — which is the common
// case during text streaming, when only ``text``/``reasoning``
// parts are mutating and the same-toolName tool-call ordering is
// stable. (See Vercel React rule ``rerender-defer-reads``.)
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const positionInTurn = useAuiState(({ message }) => {
const content = message?.content;
if (!Array.isArray(content)) return -1;
let n = -1;
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: string }).type === "tool-call" &&
(part as { toolName?: string }).toolName === toolName
) {
n += 1;
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
}
}
return -1;
});
const action = useMemo(() => {
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
// Indexed chunk streams and any stream that backfilled
// ``langchainToolCallId`` via ``tool-output-available``.
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
if (direct) return direct;
// Tier 3: position-within-turn fallback. Only kicks in when the
// card has a synthetic ``call_<run_id>`` id AND no
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
// was emitted as a single non-chunked payload AND streaming
// pre-dated the on_tool_end backfill.
if (!chatTurnId || positionInTurn < 0) return null;
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
return turnSameTool[positionInTurn] ?? null;
}, [
findByToolCallId,
findByChatTurnAndTool,
toolCallId,
langchainToolCallId,
chatTurnId,
toolName,
positionInTurn,
]);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
if (!action) return null;
if (!action.reversible) return null;
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
return null;
if (action.is_revert_action) return null;
if (action.error !== null && action.error !== undefined) return null;
if (!threadId) return null;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null);
toast.success(response.message || "Action reverted.");
} catch (err) {
// 503 means revert is gated off on this deployment — hide the
// button silently rather than nagging the user. Any other error
// is surfaced as a toast so the operator can investigate.
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
disabled={isReverting}
>
{isReverting ? (
// Spinner's typed props don't accept ``data-icon`` and
// it renders an <output>, not an <svg>, so Button's
// auto-sizing rule doesn't apply. Bare spinner +
// Button's gap handle layout.
<Spinner size="xs" />
) : (
<RotateCcw data-icon="inline-start" />
)}
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo{" "}
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
new entry to the history. Your chat is preserved only the changes the agent made to
your knowledge base or connected apps will be rolled back where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
className="gap-1.5"
>
{isReverting && <Spinner size="xs" />}
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/**
* Compact tool-call card.
*
* shadcn composition note: we intentionally use ``Card`` as a visual
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
* trigger; using ``Card`` alone preserves the rounded border, shadow,
* and ``bg-card`` token (semantic colors) without forcing a layout
* that doesn't fit. All status colors use semantic tokens no manual
* dark-mode overrides, no raw hex.
*/
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
const { toolCallId, toolName, argsText, result, status } = props;
// ``langchainToolCallId`` is a SurfSense-specific extension the
// streaming pipeline attaches to the tool-call content part so
// the Revert button can resolve its ``AgentActionLog`` row even
// when only the LC id is known. assistant-ui's
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
// spreads ``{...part}`` so the prop reaches us at runtime.
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const isError = status?.type === "incomplete" && status.reason === "error";
const isRunning = status?.type === "running" || status?.type === "requires-action";
/*
Per-card expansion state. Initial value is ``isRunning`` so a
card streaming in mounts already-expanded (no flash of
collapsed expanded on first paint), while a card loaded from
history (status="complete") mounts collapsed. The useEffect
below keeps this in lockstep with this card's own ``isRunning``
when it transitions: false true auto-expands (e.g. a tool
that re-runs after edit), true false auto-collapses once the
tool finishes. Because the dep is per-card ``isRunning`` and
not the chat-level streaming flag, sibling cards on the same
assistant turn each manage their own expansion independently.
Once ``isRunning`` is false the user controls expansion via
``onOpenChange``.
*/
const [isExpanded, setIsExpanded] = useState(isRunning);
useEffect(() => {
setIsExpanded(isRunning);
}, [isRunning]);
const errorData = status?.type === "incomplete" ? status.error : undefined;
const serializedError = useMemo(
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
[errorData]
);
const serializedResult = useMemo(
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const errorReason =
isError && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const displayName = getToolDisplayName(toolName);
const subtitle = errorReason ?? cancelledReason;
return (
<Card
className={cn(
"my-4 max-w-lg overflow-hidden",
isCancelled && "opacity-60",
isError && "border-destructive/30"
)}
>
{/*
``group`` lets the chevron (rendered as a sibling of the
main trigger button) read the Collapsible Root's
``data-[state=open]`` for rotation. The Collapsible is
fully controlled via ``isExpanded`` the useEffect
above syncs it to ``isRunning`` so the card auto-opens
while a tool streams in and auto-collapses once it
finishes. We deliberately DON'T pass ``disabled`` so
both triggers stay clickable; ``onOpenChange`` is wired
to a setter that no-ops while ``isRunning`` (see
``handleOpenChange`` below) which keeps the card pinned
open mid-stream without losing keyboard / pointer
affordance the moment streaming ends.
*/}
<Collapsible
className="group"
open={isExpanded}
onOpenChange={(next) => {
// Block manual collapse while the tool is still
// streaming — otherwise a stray click on either
// trigger would close the card and hide the live
// ``argsText`` panel mid-run. After streaming the
// user has full control again.
if (isRunning) return;
setIsExpanded(next);
}}
>
{/*
Header row: main trigger on the left (icon + title
col), Revert + chevron-trigger on the right as
siblings of the main trigger. The chevron is wrapped
in its OWN ``CollapsibleTrigger`` (Radix supports
multiple triggers per Root) so clicking the chevron
toggles the same state as clicking the title row.
The Revert button stays a separate AlertDialog
trigger and stops propagation in its onClick so it
doesn't toggle the collapsible while opening the
confirm dialog. Keeping these as flat siblings
rather than nesting Revert / chevron inside the
title trigger avoids invalid HTML
(button-in-button) and lets the Revert button
render in BOTH the collapsed and expanded states.
*/}
<div className="flex items-stretch transition-colors hover:bg-muted/50">
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
// Inset ring — Card's ``overflow-hidden`` would
// clip an ``offset-2`` ring; ``ring-inset``
// paints inside the button box.
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
)}
>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Spinner size="sm" className="text-primary" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
"text-sm font-semibold truncate",
isCancelled && "text-muted-foreground line-through",
isError && "text-destructive"
)}
>
{displayName}
</p>
{isRunning && <Badge variant="secondary">Running</Badge>}
{isError && <Badge variant="destructive">Failed</Badge>}
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
</div>
{subtitle && (
<p
className={cn(
"text-xs truncate",
isError ? "text-destructive/80" : "text-muted-foreground"
)}
>
{subtitle}
</p>
)}
</div>
</button>
</CollapsibleTrigger>
{/*
Right-side controls. The Revert button is
visible whenever the matching action is
reversible including the collapsed state
but ``ToolCardRevertButton`` itself returns
``null`` while a tool is still running because
no action-log row exists yet, so it doesn't
need an explicit ``isRunning`` gate here.
*/}
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
<ToolCardRevertButton
toolCallId={toolCallId}
toolName={toolName}
langchainToolCallId={langchainToolCallId}
/>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-200",
"group-data-[state=open]:rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
</div>
</div>
{/*
CollapsibleContent body auto-open while streaming
(see ``open`` prop above) so the live ``argsText``
streams into the Inputs panel directly, no need for
a separate "Live input" panel. Native
``overflow-auto`` instead of ``ScrollArea`` because
Radix's Viewport can let content bleed past
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
the column wrappers guarantees ``break-all`` wraps
correctly within the bounded ``max-w-lg`` Card.
*/}
<CollapsibleContent>
<Separator />
<div className="flex flex-col gap-3 px-5 py-3">
{(argsText || isRunning) && (
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
{argsText ? (
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{argsText}
</pre>
) : (
// Bridges the brief gap between
// ``tool-input-start`` (creates the
// card, ``argsText`` undefined) and
// the first ``tool-input-delta``.
<p className="px-3 py-2 text-xs italic text-muted-foreground">
Waiting for input
</p>
)}
</NestedScroll>
</div>
)}
{!isCancelled && result !== undefined && (
<>
<Separator />
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Result</p>
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{typeof result === "string" ? result : serializedResult}
</pre>
</NestedScroll>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
};
/**
* Wrap any tool-call UI so cards under an active delegating ``task`` span indent.
* Applied to named tool components as well as ``ToolFallback`` only ``ToolFallback``
* would miss delegated tools otherwise.
*/
export function withDelegationSpanIndent(
Component: ToolCallMessagePartComponent
): ToolCallMessagePartComponent {
const Wrapped: ToolCallMessagePartComponent = (props) => {
const metadata = (props as { metadata?: Record<string, unknown> }).metadata;
const indent = shouldIndentToolCallForDelegationSpan(props.toolName, metadata);
const inner = <Component {...props} />;
return indent ? <div className={cn(DELEGATION_SPAN_INDENT_CLASS)}>{inner}</div> : inner;
};
Wrapped.displayName = `withDelegationSpanIndent(${Component.displayName ?? Component.name ?? "ToolUI"})`;
return Wrapped;
}
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
if (isDoomLoopInterrupt(props.result)) {
return <DoomLoopApprovalToolUI {...props} />;
}
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;
};

View file

@ -1,2 +0,0 @@
export { PagerChrome } from "./pager-chrome";
export { withBundleStep } from "./with-bundle-step";

View file

@ -1,61 +0,0 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useHitlBundle } from "@/lib/hitl";
/**
* 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.
*/
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

@ -1,37 +0,0 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import type { ComponentType } from "react";
import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl";
import { PagerChrome } from "./pager-chrome";
/**
* Wrap a tool-ui card so that, when a multi-card HITL bundle is active:
* - cards belonging to the bundle but not the current step render ``null``;
* - the current-step card renders normally and is followed by ``PagerChrome``.
*
* Cards stay completely unchanged the wrapper provides the
* ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions
* against the right ``toolCallId`` instead of firing the global event.
*/
export function withBundleStep<P extends ToolCallMessagePartProps<any, any>>(
Component: ComponentType<P>
): ComponentType<P> {
function BundleStepWrapped(props: P) {
const bundle = useHitlBundle();
const toolCallId = props.toolCallId;
const inBundle = bundle?.isInBundle(toolCallId) ?? false;
const isStep = bundle?.isCurrentStep(toolCallId) ?? false;
if (bundle && inBundle && !isStep) return null;
return (
<ToolCallIdProvider toolCallId={toolCallId}>
<Component {...props} />
{bundle && isStep ? <PagerChrome /> : null}
</ToolCallIdProvider>
);
}
BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`;
return BundleStepWrapped as ComponentType<P>;
}

View file

@ -1,405 +0,0 @@
"use client";
import { format } from "date-fns";
import { TagInput, type Tag as TagType } from "emblor";
import { useAtomValue, useSetAtom } from "jotai";
import { CalendarIcon, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useMediaQuery } from "@/hooks/use-media-query";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
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(", ");
}
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}
/>
);
}
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`;
}
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>
);
}
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 && (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{extraFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label
htmlFor={`extra-field-${field.key}`}
className="text-xs font-medium text-muted-foreground"
>
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={`extra-field-${field.key}`}
type={field.type}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
))}
</div>
)}
<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>
);
}
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -1,62 +0,0 @@
import { useEffect, useRef, useState } from "react";
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
interface HitlInterruptLike {
__decided__?: string | null;
__completed__?: boolean;
}
const MINIMUM_SHIMMER_MS = 500;
const FALLBACK_TIMEOUT_MS = 30_000;
/**
* State machine for HITL approval card phases.
*
* Phases:
* pending waiting for user decision (show buttons)
* processing user approved/edited, waiting for backend (shimmer)
* complete backend responded with __completed__ (done text)
* rejected user rejected (cancelled text)
*/
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);
// processing → complete when __completed__ arrives (with min shimmer duration)
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__]);
// Fallback: processing → complete after 30s even if __completed__ never arrives
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"),
};
}

View file

@ -1,19 +0,0 @@
/**
* Indent tool-call cards that belong to an open delegating ``task`` episode.
*
* The backend only stamps ``metadata.spanId`` on tool SSE / persisted parts
* while a ``task`` is active (see ``AgentEventRelayState.tool_activity_metadata``),
* so its presence is sufficient. The opening ``task`` row itself carries the
* same span id but stays flush it is the header of the delegation.
*/
export function shouldIndentToolCallForDelegationSpan(
toolName: string,
metadata: Record<string, unknown> | undefined
): boolean {
if (toolName === "task") return false;
const v = metadata?.spanId;
return typeof v === "string" && v.trim().length > 0;
}
export const DELEGATION_SPAN_INDENT_CLASS = "pl-3 sm:ml-4";

View file

@ -1,153 +0,0 @@
"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;
}
/**
* Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts
* keep their direct ``window`` dispatch path so N=1 UX is unchanged.
*/
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("|");
// Derived-state-from-props: reset staging + step when the bundle changes.
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;
});
// Mirror the staged decision onto the card immediately so prev/next
// nav doesn't re-show approve/reject buttons for already-decided cards.
// Submit's ``hitl-decision`` event re-applies these (no-op) and runs
// the actual resume.
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

@ -1,22 +0,0 @@
export {
type BundleSubmit,
type HitlBundleAPI,
HitlBundleProvider,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle-context";
export {
type HitlRenderTarget,
HitlRenderTargetProvider,
useHitlRenderTarget,
withHitlInTimeline,
} from "./render-target";
export type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -1,48 +0,0 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { createContext, useContext } from "react";
import { isInterruptResult } from "./types";
/**
* Where this tool-call card is currently rendering.
*
* - ``"body"`` (default) assistant-ui's ``MessagePrimitive.Parts`` renders
* the card inside the message bubble.
* - ``"timeline"`` ``ThinkingStepsDisplay`` renders the SAME component
* inline under the matching step row so the HITL approval lives in the
* chain-of-thought instead of as a standalone card in the message body.
*
* The two render targets share one component implementation; the context
* lets the body render skip itself when the timeline copy will show the
* card, avoiding a double-render.
*/
export type HitlRenderTarget = "body" | "timeline";
const HitlRenderTargetContext = createContext<HitlRenderTarget>("body");
export const HitlRenderTargetProvider = HitlRenderTargetContext.Provider;
export function useHitlRenderTarget(): HitlRenderTarget {
return useContext(HitlRenderTargetContext);
}
/**
* Hide the body render of a tool-call whose result is a HITL interrupt.
* The same component is mounted again inside ``ThinkingStepsDisplay``
* with ``HitlRenderTargetProvider value="timeline"`` that copy renders
* normally, so the card "moves" from the message body to the timeline.
*
* Pure pass-through for non-HITL results AND for the timeline render.
*/
export function withHitlInTimeline(
Component: ToolCallMessagePartComponent
): ToolCallMessagePartComponent {
const Wrapped: ToolCallMessagePartComponent = (props) => {
const target = useHitlRenderTarget();
if (target === "body" && isInterruptResult(props.result)) return null;
return <Component {...props} />;
};
Wrapped.displayName = `withHitlInTimeline(${Component.displayName ?? Component.name ?? "ToolUI"})`;
return Wrapped;
}

View file

@ -1,45 +0,0 @@
/**
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
*
* Every tool-ui component that handles interrupts should import from here
* instead of defining its own `InterruptResult` / `isInterruptResult`.
*/
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>;
};
}

View file

@ -1,41 +0,0 @@
/**
* Shared hook for dispatching HITL decisions.
*
* Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle
* is active (``HitlBundleProvider``), the dispatch is intercepted and staged
* against this card's ``toolCallId`` so the orchestrator can submit one
* ordered N-decision payload. With no bundle active (N=1 path), it falls back
* to the legacy ``window`` event the host listens for in ``page.tsx``.
*/
import { useCallback } from "react";
import { useHitlBundle, useToolCallIdContext } from "./bundle-context";
import type { HitlDecision } from "./types";
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") {
// Tool-ui cards stage one decision per call; a multi-decision
// dispatch into an active bundle would silently drop tail entries.
// eslint-disable-next-line no-console
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 };
}