/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useRef } from 'react'; import { useStateAndRef } from '../useStateAndRef.js'; import type { QueuedSubmission } from './types.js'; /** * State-backed queued-submissions store with serialized drain ownership. * * Provides: * - `queuedSubmissions` (reactive state for rendering) * - `queuedSubmissionsRef` (stable ref for callback access) * - `enqueueSubmission` (immutable append — updates both ref and state) * - `enqueueSubmissionFirst` (immutable front insertion of a NEW submission, * minting a fresh `queueId`; used for post-cancel resume priority, issue * #3169) * - `requeueSubmission` (immutable front insertion for retry — deliberately * preserves the existing `queueId` of an already-identified item) * - `dequeueSubmission` (FIFO shift — updates both ref and state) * - `clearSubmissions` (clear — updates both ref and state) * * All mutations go through `setStateInternal` from useStateAndRef, which * synchronously updates the ref AND schedules a React state update. This * eliminates stale-closure/race conditions between the ref reads in * scheduleNextQueuedSubmission/submitQuery and the reactive state that * drives the QueuedMessagesPanel. * * Serialized draining: `tryReserveDrain` / `releaseDrain` provide a canonical * ownership token that ensures exactly one drain attempt is in-flight at any * time, preventing the double-drain race between the idle-effect trigger and * the runSubmitQueryCore finally-block trigger (issue #2296). */ export function useQueuedSubmissions() { const [queuedSubmissions, queuedSubmissionsRef, setQueuedSubmissions] = useStateAndRef([]); // Canonical drain owner: true while a scheduleNextQueuedSubmission call has // dequeued an item but the resulting submitQuery hasn't had a chance to set // isResponding(true) yet. Prevents concurrent triggers from each dequeuing // a separate item. const drainInFlightRef = useRef(false); const nextQueueIdRef = useRef(0); const withQueueId = useCallback( (submission: QueuedSubmission): QueuedSubmission => { const queueId = nextQueueIdRef.current; nextQueueIdRef.current = queueId + 1; return { ...submission, queueId }; }, [], ); const enqueueSubmission = useCallback( (submission: QueuedSubmission): void => { setQueuedSubmissions((prev) => [...prev, withQueueId(submission)]); }, [setQueuedSubmissions, withQueueId], ); const enqueueSubmissionFirst = useCallback( (submission: QueuedSubmission): void => { setQueuedSubmissions((prev) => [withQueueId(submission), ...prev]); }, [setQueuedSubmissions, withQueueId], ); const requeueSubmission = useCallback( (submission: QueuedSubmission): void => { setQueuedSubmissions((prev) => [submission, ...prev]); }, [setQueuedSubmissions], ); const dequeueSubmission = useCallback((): QueuedSubmission | undefined => { if (queuedSubmissionsRef.current.length === 0) { return undefined; } const [first, ...rest] = queuedSubmissionsRef.current; setQueuedSubmissions(rest); return first; }, [queuedSubmissionsRef, setQueuedSubmissions]); const clearSubmissions = useCallback((): void => { setQueuedSubmissions([]); }, [setQueuedSubmissions]); const tryReserveDrain = useCallback((): boolean => { if (drainInFlightRef.current) { return false; } drainInFlightRef.current = true; return true; }, []); const releaseDrain = useCallback((): void => { drainInFlightRef.current = false; }, []); return { queuedSubmissions, queuedSubmissionsRef, enqueueSubmission, enqueueSubmissionFirst, requeueSubmission, dequeueSubmission, clearSubmissions, tryReserveDrain, releaseDrain, }; } export type UseQueuedSubmissionsReturn = ReturnType< typeof useQueuedSubmissions >;