mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
chat: unify HITL approval UX behind a single paginated card and harden timeline supersede.
This commit is contained in:
parent
89e4953800
commit
2e132513be
25 changed files with 604 additions and 1157 deletions
|
|
@ -7,7 +7,7 @@ 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 type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
|
||||
import { isInterruptResult } from "../types";
|
||||
import { useHitlDecision } from "../use-hitl-decision";
|
||||
import { useHitlPhase } from "../use-hitl-phase";
|
||||
|
|
@ -178,7 +178,7 @@ export function isDoomLoopInterrupt(result: unknown): boolean {
|
|||
* ``isDoomLoopInterrupt(result)`` is true. Caller is responsible for
|
||||
* the discrimination; this card receives a known ``InterruptResult``.
|
||||
*/
|
||||
export const DoomLoopApproval: HitlApprovalCard = ({ toolName, args, result }) => {
|
||||
export const DoomLoopApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
return (
|
||||
<DoomLoopCardView
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ 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 type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
|
||||
import { useHitlDecision } from "../use-hitl-decision";
|
||||
import { useHitlPhase } from "../use-hitl-phase";
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ function GenericApprovalCardView({
|
|||
* guard; this card receives a known ``InterruptResult`` and skips the
|
||||
* defensive runtime check.
|
||||
*/
|
||||
export const GenericHitlApproval: HitlApprovalCard = ({ toolName, args, result }) => {
|
||||
export const GenericHitlApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
return (
|
||||
<GenericApprovalCardView
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { HitlDecision } from "../types";
|
||||
|
||||
/**
|
||||
* Decisions are keyed by step index (not toolCallId) because the
|
||||
* resume protocol is positional — backend pairs ``decisions[i]`` with
|
||||
* ``action_requests[i]``. ``stage`` always targets the active step,
|
||||
* so per-tool bodies stay tcId-agnostic.
|
||||
*/
|
||||
export interface HitlApprovalAPI {
|
||||
total: number;
|
||||
currentStep: number;
|
||||
decisions: ReadonlyArray<HitlDecision | undefined>;
|
||||
stage: (decision: HitlDecision) => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goToStep: (i: number) => void;
|
||||
canAdvance: boolean;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export const HitlApprovalContext = createContext<HitlApprovalAPI | null>(null);
|
||||
|
||||
export function useHitlApproval(): HitlApprovalAPI | null {
|
||||
return useContext(HitlApprovalContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
"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<string, unknown>,
|
||||
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<InterruptResult>),
|
||||
__interrupt__: true,
|
||||
action_requests: [baseAction],
|
||||
review_configs: reviewConfig ? [reviewConfig] : [],
|
||||
} as InterruptResult;
|
||||
|
||||
if (stagedDecision) {
|
||||
(sliced as unknown as Record<string, unknown>).__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<string, unknown>;
|
||||
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<HitlApprovalAPI>(
|
||||
() => ({
|
||||
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<string, unknown>,
|
||||
argsText: undefined,
|
||||
result: sliced,
|
||||
langchainToolCallId: undefined,
|
||||
status: stagedDecision ? "completed" : "running",
|
||||
};
|
||||
|
||||
return (
|
||||
<HitlApprovalContext.Provider value={api}>
|
||||
<div className="space-y-2">
|
||||
<div key={`approval-step-${currentStep}`}>
|
||||
<Body {...bodyProps} />
|
||||
</div>
|
||||
{isMulti && (
|
||||
<PagerBar
|
||||
currentStep={currentStep}
|
||||
total={total}
|
||||
stagedCount={stagedCount}
|
||||
canAdvance={canAdvance}
|
||||
canSubmit={canSubmit}
|
||||
actionName={action.name}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HitlApprovalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5 text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onPrev}
|
||||
disabled={currentStep === 0}
|
||||
aria-label="Previous approval"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-medium tabular-nums">
|
||||
{currentStep + 1} / {total}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stagedCount} of {total} decided
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onNext}
|
||||
disabled={!canAdvance || currentStep >= total - 1}
|
||||
aria-label="Next approval"
|
||||
title={!canAdvance ? "Decide on this action first" : undefined}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-2 truncate text-xs text-muted-foreground" title={actionName}>
|
||||
{getToolDisplayName(actionName)}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
title={canSubmit ? "Submit decisions" : "Decide every action first"}
|
||||
>
|
||||
Submit decisions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export type { HitlApprovalAPI } from "./approval-context";
|
||||
export { HitlApprovalContext, useHitlApproval } from "./approval-context";
|
||||
export { HitlApprovalCard } from "./hitl-approval-card";
|
||||
export {
|
||||
PendingInterruptProvider,
|
||||
type PendingInterruptState,
|
||||
type PendingInterruptValue,
|
||||
usePendingInterrupt,
|
||||
} from "./pending-interrupt-context";
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { HitlDecision } from "../types";
|
||||
|
||||
/** Snapshot of one in-flight HITL interrupt; ``null`` when nothing is pending. */
|
||||
export interface PendingInterruptState {
|
||||
threadId: number;
|
||||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
bundleToolCallIds: string[];
|
||||
}
|
||||
|
||||
export interface PendingInterruptValue {
|
||||
pendingInterrupt: PendingInterruptState | null;
|
||||
onSubmit: (decisions: HitlDecision[]) => void;
|
||||
}
|
||||
|
||||
const PendingInterruptContext = createContext<PendingInterruptValue | null>(null);
|
||||
|
||||
/**
|
||||
* Bridges page-level interrupt state to the Timeline, which is mounted
|
||||
* by assistant-ui and can't be prop-drilled. Mount once at the chat
|
||||
* page root.
|
||||
*/
|
||||
export function PendingInterruptProvider({
|
||||
pendingInterrupt,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
pendingInterrupt: PendingInterruptState | null;
|
||||
onSubmit: (decisions: HitlDecision[]) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PendingInterruptContext.Provider value={{ pendingInterrupt, onSubmit }}>
|
||||
{children}
|
||||
</PendingInterruptContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePendingInterrupt(): PendingInterruptValue | null {
|
||||
return useContext(PendingInterruptContext);
|
||||
}
|
||||
|
|
@ -1,157 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
|
||||
export {
|
||||
HitlBundleProvider,
|
||||
ToolCallIdProvider,
|
||||
useHitlBundle,
|
||||
useToolCallIdContext,
|
||||
} from "./bundle-context";
|
||||
export { PagerChrome } from "./pager-chrome";
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
|
||||
export {
|
||||
type BundleSubmit,
|
||||
type HitlBundleAPI,
|
||||
HitlBundleProvider,
|
||||
PagerChrome,
|
||||
ToolCallIdProvider,
|
||||
useHitlBundle,
|
||||
useToolCallIdContext,
|
||||
} from "./bundle";
|
||||
type HitlApprovalAPI,
|
||||
HitlApprovalCard,
|
||||
PendingInterruptProvider,
|
||||
type PendingInterruptState,
|
||||
type PendingInterruptValue,
|
||||
useHitlApproval,
|
||||
usePendingInterrupt,
|
||||
} from "./approval";
|
||||
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
|
||||
export {
|
||||
closeHitlEditPanelAtom,
|
||||
type ExtraField,
|
||||
|
|
@ -18,13 +18,13 @@ export {
|
|||
openHitlEditPanelAtom,
|
||||
} from "./edit-panel";
|
||||
export type {
|
||||
HitlApprovalCard,
|
||||
HitlApprovalCardProps,
|
||||
HitlDecision,
|
||||
HitlPhase,
|
||||
InterruptActionRequest,
|
||||
InterruptResult,
|
||||
InterruptReviewConfig,
|
||||
PerToolApprovalCard,
|
||||
PerToolApprovalCardProps,
|
||||
} from "./types";
|
||||
export { isInterruptResult } from "./types";
|
||||
export { useHitlDecision } from "./use-hitl-decision";
|
||||
|
|
|
|||
|
|
@ -41,11 +41,19 @@ export interface HitlDecision {
|
|||
|
||||
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
|
||||
|
||||
export interface HitlApprovalCardProps {
|
||||
export interface PerToolApprovalCardProps {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
result: InterruptResult;
|
||||
}
|
||||
|
||||
export type HitlApprovalCard = (props: HitlApprovalCardProps) => ReactNode;
|
||||
/**
|
||||
* Type signature for per-tool fallback approval cards (e.g.
|
||||
* ``GenericHitlApproval``, ``DoomLoopApproval``) mounted by
|
||||
* ``FallbackToolBody`` for unregistered HITL tools.
|
||||
*
|
||||
* Distinct from ``HitlApprovalCard`` (the high-level multi/single
|
||||
* chrome) — this is the per-tool body that the chrome wraps.
|
||||
*/
|
||||
export type PerToolApprovalCard = (props: PerToolApprovalCardProps) => ReactNode;
|
||||
|
|
|
|||
|
|
@ -1,44 +1,31 @@
|
|||
import { useCallback } from "react";
|
||||
import { useHitlBundle, useToolCallIdContext } from "./bundle/bundle-context";
|
||||
import { useHitlApproval } from "./approval/approval-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.
|
||||
* Per-tool components always call ``dispatch([decision])``. We route
|
||||
* through ``HitlApprovalContext`` when mounted inside an approval
|
||||
* card (so multi-approval can stage and pager-navigate), and fall
|
||||
* back to the ``hitl-decision`` window event for standalone callers.
|
||||
*/
|
||||
export function useHitlDecision() {
|
||||
const bundle = useHitlBundle();
|
||||
const toolCallId = useToolCallIdContext();
|
||||
const approval = useHitlApproval();
|
||||
|
||||
const dispatch = useCallback(
|
||||
(decisions: HitlDecision[]) => {
|
||||
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
|
||||
if (approval && 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
|
||||
"[hitl] dispatch received %d decisions inside an approval card; only [0] will be staged",
|
||||
decisions.length
|
||||
);
|
||||
}
|
||||
bundle.stage(toolCallId, decisions[0]);
|
||||
approval.stage(decisions[0]);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||
},
|
||||
[bundle, toolCallId]
|
||||
[approval]
|
||||
);
|
||||
|
||||
return { dispatch };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue