mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
157 lines
4.3 KiB
TypeScript
157 lines
4.3 KiB
TypeScript
"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>;
|
|
}
|