"use client"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { type FC, useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { getToolDisplayName } from "@/contracts/enums/toolIcons"; import { FallbackToolBody, getToolComponent, type TimelineToolProps, } from "@/features/chat-messages/timeline/tool-registry"; import type { HitlDecision, InterruptActionRequest, InterruptResult, InterruptReviewConfig, } from "../types"; import { type HitlApprovalAPI, HitlApprovalContext } from "./approval-context"; import type { PendingInterruptState } from "./pending-interrupt-context"; /** * Narrow the bundle interrupt to the active step so per-tool bodies * see the same single-action shape they're written against. Mirrors * any staged decision onto ``__decided__`` (and edited args onto * ``args``) so revisiting a decided step via Prev shows the past * choice instead of pristine Approve/Reject buttons. */ function sliceForStep( interruptData: Record, action: InterruptActionRequest, reviewConfig: InterruptReviewConfig | undefined, stagedDecision: HitlDecision | undefined ): InterruptResult { const baseAction = stagedDecision?.type === "edit" && stagedDecision.edited_action ? { ...action, args: stagedDecision.edited_action.args } : action; const sliced: InterruptResult = { ...(interruptData as Partial), __interrupt__: true, action_requests: [baseAction], review_configs: reviewConfig ? [reviewConfig] : [], } as InterruptResult; if (stagedDecision) { (sliced as unknown as Record).__decided__ = stagedDecision.type; } return sliced; } /** * Single chrome for every HITL approval flow. Branches on * ``action_requests.length``: 1 → per-tool body alone with auto- * submit on first decision; ≥2 → per-tool body + inline pager + * Submit-decisions (fires only once every step has a decision). * Decisions are positional to match the resume protocol. */ export const HitlApprovalCard: FC<{ pendingInterrupt: PendingInterruptState; onSubmit: (decisions: HitlDecision[]) => void; }> = ({ pendingInterrupt, onSubmit }) => { const interruptData = pendingInterrupt.interruptData as InterruptResult & Record; const actionRequests = (interruptData.action_requests ?? []) as InterruptActionRequest[]; const reviewConfigs = (interruptData.review_configs ?? []) as InterruptReviewConfig[]; const total = actionRequests.length; const isMulti = total >= 2; const [currentStep, setCurrentStep] = useState(0); const [decisions, setDecisions] = useState<(HitlDecision | undefined)[]>(() => Array.from({ length: total }, () => undefined) ); // Reset on a new interrupt-request while still mounted (rapid // back-to-back resumes), otherwise stale decisions would leak. const [prevActionsRef, setPrevActionsRef] = useState(actionRequests); if (prevActionsRef !== actionRequests) { setPrevActionsRef(actionRequests); setCurrentStep(0); setDecisions(Array.from({ length: total }, () => undefined)); } const submitFromDecisions = useCallback( (next: (HitlDecision | undefined)[]) => { if (next.length !== total) return; if (next.some((d) => d === undefined)) return; onSubmit(next as HitlDecision[]); }, [onSubmit, total] ); const stage = useCallback( (decision: HitlDecision) => { // Compute next array outside the setter so the side effect // (auto-submit / step advance) runs once under StrictMode. const updated = decisions.slice(); updated[currentStep] = decision; setDecisions(updated); if (!isMulti) { submitFromDecisions(updated); return; } // Skip to the next undecided step rather than +1 so users // who jumped via Prev don't get pulled back to a decided // step. let target = currentStep; for (let i = currentStep + 1; i < updated.length; i++) { if (updated[i] === undefined) { target = i; break; } } if (target !== currentStep) setCurrentStep(target); }, [currentStep, decisions, isMulti, submitFromDecisions] ); const next = useCallback(() => { setCurrentStep((s) => Math.min(s + 1, Math.max(0, total - 1))); }, [total]); const prev = useCallback(() => { setCurrentStep((s) => Math.max(s - 1, 0)); }, []); const goToStep = useCallback( (i: number) => { if (i < 0 || i >= total) return; setCurrentStep(i); }, [total] ); const submit = useCallback(() => { submitFromDecisions(decisions); }, [decisions, submitFromDecisions]); const stagedCount = useMemo(() => decisions.filter((d) => d !== undefined).length, [decisions]); const canSubmit = stagedCount === total && total > 0; const canAdvance = decisions[currentStep] !== undefined; const api = useMemo( () => ({ total, currentStep, decisions, stage, next, prev, goToStep, canAdvance, canSubmit, }), [total, currentStep, decisions, stage, next, prev, goToStep, canAdvance, canSubmit] ); if (total === 0) return null; const action = actionRequests[currentStep]; const reviewConfig = reviewConfigs[currentStep]; const stagedDecision = decisions[currentStep]; const sliced = sliceForStep(interruptData, action, reviewConfig, stagedDecision); const Body = getToolComponent(action.name) ?? FallbackToolBody; const bodyProps: TimelineToolProps = { // Per-step key remounts the body on navigation so per-tool // internal state (useHitlPhase, edit drafts) doesn't bleed // between steps. toolCallId: pendingInterrupt.bundleToolCallIds[currentStep] ?? `step-${currentStep}`, toolName: action.name, args: (sliced.action_requests[0]?.args ?? {}) as Record, argsText: undefined, result: sliced, langchainToolCallId: undefined, status: stagedDecision ? "completed" : "running", }; return (
{isMulti && ( )}
); }; const PagerBar: FC<{ currentStep: number; total: number; stagedCount: number; canAdvance: boolean; canSubmit: boolean; actionName: string; onPrev: () => void; onNext: () => void; onSubmit: () => void; }> = ({ currentStep, total, stagedCount, canAdvance, canSubmit, actionName, onPrev, onNext, onSubmit, }) => (
{currentStep + 1} / {total} · {stagedCount} of {total} decided {getToolDisplayName(actionName)}
);