Files
kandev/apps/web/hooks/domains/session/use-queue.ts
T
2026-08-06 13:45:55 +01:00

421 lines
12 KiB
TypeScript

import { useEffect, useCallback, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import {
queueMessage,
clearQueue,
drainQueuedMessage,
getQueueStatus,
updateQueuedMessage,
removeQueuedEntry,
mergeQueuedEntry,
QueueEntryNotFoundError,
sendQueuedNow,
} from "@/lib/api/domains/queue-api";
import type { QueueMessageParams } from "@/lib/api/domains/queue-api";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
const EMPTY_ENTRIES: QueuedMessage[] = [];
export type MessageAttachment = {
type: string;
data?: string;
attachment_id?: string;
mime_type: string;
name?: string;
size_bytes?: number;
delivery_mode?: "prompt" | "path";
};
export type QueueMessageInput = {
taskId: string;
content: string;
model?: string;
planMode?: boolean;
attachments?: MessageAttachment[];
entityReferences?: EntityReference[];
contextFilesMeta?: QueueMessageParams["context_files"];
};
/** Selectors over the queue slice for one session. */
function useQueueState(sessionId: string | null) {
const entries = useAppStore((state) =>
sessionId ? (state.queue.bySessionId[sessionId] ?? EMPTY_ENTRIES) : EMPTY_ENTRIES,
);
const meta = useAppStore((state) =>
sessionId ? state.queue.metaBySessionId[sessionId] : undefined,
);
const isLoading = useAppStore((state) =>
sessionId ? (state.queue.isLoading[sessionId] ?? false) : false,
);
const setQueueEntries = useAppStore((state) => state.setQueueEntries);
const removeQueueEntry = useAppStore((state) => state.removeQueueEntry);
const setQueueLoading = useAppStore((state) => state.setQueueLoading);
const cancellationPending = useAppStore(
(state) =>
(sessionId ? state.taskSessions.items[sessionId]?.cancellation_pending : false) === true,
);
return {
entries,
meta,
isLoading,
cancellationPending,
setQueueEntries,
removeQueueEntry,
setQueueLoading,
};
}
type QueueActionsArgs = {
sessionId: string | null;
setQueueEntries: ReturnType<typeof useQueueState>["setQueueEntries"];
removeQueueEntry: ReturnType<typeof useQueueState>["removeQueueEntry"];
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"];
metaMax: number | undefined;
};
function useDrainNextAction(
sessionId: string | null,
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"],
refetch: (sid: string) => Promise<void>,
) {
return useCallback(async () => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
try {
await drainQueuedMessage(sessionId);
await refetch(sessionId);
} finally {
setQueueLoading(sessionId, false);
}
}, [sessionId, refetch, setQueueLoading]);
}
function useSendNowAction(
sessionId: string | null,
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"],
refetch: (sid: string) => Promise<void>,
) {
return useCallback(
async (scope: "entry" | "all", entryId?: string) => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
let mutationFailed = false;
let mutationError: unknown;
try {
try {
await sendQueuedNow({
session_id: sessionId,
scope,
...(scope === "entry" ? { entry_id: entryId } : {}),
});
} catch (err) {
mutationFailed = true;
mutationError = err;
}
try {
await refetch(sessionId);
} catch (reconcileError) {
if (mutationFailed) throw mutationError;
throw reconcileError;
}
if (mutationFailed) throw mutationError;
} finally {
setQueueLoading(sessionId, false);
}
},
[sessionId, refetch, setQueueLoading],
);
}
function useQueueRefetch(
setQueueEntries: ReturnType<typeof useQueueState>["setQueueEntries"],
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"],
) {
const refetchVersion = useRef<Record<string, number>>({});
const invalidate = useCallback((sid: string) => {
refetchVersion.current[sid] = (refetchVersion.current[sid] ?? 0) + 1;
}, []);
const refetch = useCallback(
async (sid: string) => {
const version = (refetchVersion.current[sid] ?? 0) + 1;
refetchVersion.current[sid] = version;
try {
setQueueLoading(sid, true);
const status = await getQueueStatus(sid);
if (refetchVersion.current[sid] !== version) return;
setQueueEntries(sid, status.entries ?? [], { count: status.count, max: status.max });
} finally {
if (refetchVersion.current[sid] === version) setQueueLoading(sid, false);
}
},
[setQueueEntries, setQueueLoading],
);
return { refetch, invalidate };
}
/** Build an action set bound to the supplied session + slice setters. */
function useQueueActions({
sessionId,
setQueueEntries,
removeQueueEntry,
setQueueLoading,
metaMax,
}: QueueActionsArgs) {
const { refetch, invalidate: invalidateRefetch } = useQueueRefetch(
setQueueEntries,
setQueueLoading,
);
const queue = useCallback(
async ({
taskId,
content,
model,
planMode,
attachments,
entityReferences,
contextFilesMeta,
}: QueueMessageInput) => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
try {
await queueMessage({
session_id: sessionId,
task_id: taskId,
content,
model,
plan_mode: planMode,
attachments,
entity_references: entityReferences,
...(contextFilesMeta ? { context_files: contextFilesMeta } : {}),
});
await refetch(sessionId);
} finally {
setQueueLoading(sessionId, false);
}
},
[sessionId, refetch, setQueueLoading],
);
const clearAll = useCallback(async () => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
let mutationFailed = false;
let mutationError: unknown;
try {
try {
await clearQueue(sessionId);
// Invalidate any pre-clear status request before publishing the
// optimistic empty state. The explicit refetch below then owns the
// newest version and replaces it with the backend's exact result.
invalidateRefetch(sessionId);
setQueueEntries(sessionId, [], { count: 0, max: metaMax ?? 0 });
} catch (err) {
mutationFailed = true;
mutationError = err;
}
try {
await refetch(sessionId);
} catch (reconcileError) {
// Preserve the mutation error when both operations fail; it best
// explains why the requested action did not complete.
if (mutationFailed) throw mutationError;
throw reconcileError;
}
if (mutationFailed) throw mutationError;
} finally {
setQueueLoading(sessionId, false);
}
}, [sessionId, setQueueEntries, setQueueLoading, metaMax, refetch, invalidateRefetch]);
const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch);
const sendNow = useSendNowAction(sessionId, setQueueLoading, refetch);
const sendEntryNow = useCallback((entryId: string) => sendNow("entry", entryId), [sendNow]);
const sendAllNow = useCallback(() => sendNow("all"), [sendNow]);
const { editEntry, removeEntry, mergeEntry } = useEntryMutations({
sessionId,
removeQueueEntry,
refetch,
});
return {
refetch,
queue,
clearAll,
drainNext,
sendEntryNow,
sendAllNow,
editEntry,
removeEntry,
mergeEntry,
};
}
type EntryMutationsArgs = {
sessionId: string | null;
removeQueueEntry: ReturnType<typeof useQueueState>["removeQueueEntry"];
refetch: (sid: string) => Promise<void>;
};
/** Entry-level mutations (edit / remove / merge) that refetch on success and
* resync on a drain race (QueueEntryNotFoundError). */
function useEntryMutations({ sessionId, removeQueueEntry, refetch }: EntryMutationsArgs) {
const editEntry = useCallback(
async (
entryId: string,
content: string,
attachments?: MessageAttachment[],
entityReferences: EntityReference[] = [],
) => {
if (!sessionId) return;
try {
await updateQueuedMessage({
session_id: sessionId,
entry_id: entryId,
content,
attachments,
entity_references: entityReferences,
});
await refetch(sessionId);
} catch (err) {
if (err instanceof QueueEntryNotFoundError) {
await refetch(sessionId);
}
throw err;
}
},
[sessionId, refetch],
);
const removeEntry = useCallback(
async (entryId: string) => {
if (!sessionId) return;
removeQueueEntry(sessionId, entryId);
let mutationFailed = false;
let mutationError: unknown;
try {
await removeQueuedEntry({ session_id: sessionId, entry_id: entryId });
} catch (err) {
mutationFailed = true;
mutationError = err;
}
try {
await refetch(sessionId);
} catch (reconcileError) {
if (mutationFailed && !(mutationError instanceof QueueEntryNotFoundError)) {
throw mutationError;
}
throw reconcileError;
}
// A drain/remove race is already reflected by the authoritative status,
// so it is a successful UI outcome. Other failures restore state above
// and remain actionable to the caller.
if (mutationFailed && !(mutationError instanceof QueueEntryNotFoundError)) {
throw mutationError;
}
},
[sessionId, refetch, removeQueueEntry],
);
const mergeEntry = useCallback(
async (entryId: string, userId?: string) => {
if (!sessionId) return;
try {
await mergeQueuedEntry({ session_id: sessionId, entry_id: entryId, user_id: userId });
await refetch(sessionId);
} catch (err) {
if (err instanceof QueueEntryNotFoundError) {
await refetch(sessionId);
}
throw err;
}
},
[sessionId, refetch],
);
return { editEntry, removeEntry, mergeEntry };
}
/**
* Reactive view over the per-session message queue plus optimistic mutators.
*
* - `entries` — ordered FIFO list (head at index 0) drained one-per-turn
* - `count` / `max` — capacity snapshot from server
* - Edit and remove rely on entry-level UUIDs: when a drain wins the race, the
* server returns `entry_not_found` and we refetch to resync the local list.
*/
export function useQueue(sessionId: string | null) {
const state = useQueueState(sessionId);
const { entries, meta, isLoading, cancellationPending } = state;
const connectionStatus = useAppStore((appState) => appState.connection.status);
const {
refetch,
queue,
clearAll,
drainNext,
sendEntryNow,
sendAllNow,
editEntry,
removeEntry,
mergeEntry,
} = useQueueActions({
sessionId,
setQueueEntries: state.setQueueEntries,
removeQueueEntry: state.removeQueueEntry,
setQueueLoading: state.setQueueLoading,
metaMax: meta?.max,
});
useEffect(() => {
if (!sessionId) return;
if (connectionStatus !== "connected") return;
void refetch(sessionId).catch((err) => {
console.error("Failed to fetch queue status:", err);
});
}, [sessionId, connectionStatus, refetch]);
useForegroundRefresh(
() => {
if (!sessionId) return;
if (connectionStatus !== "connected") return;
return refetch(sessionId).catch((err) => {
console.error("Failed to fetch queue status after foreground refresh:", err);
});
},
Boolean(sessionId),
sessionId,
);
const refetchBound = useCallback(
() => (sessionId ? refetch(sessionId) : Promise.resolve()),
[sessionId, refetch],
);
return {
entries,
count: meta?.count ?? entries.length,
max: meta?.max ?? 0,
isFull: meta ? meta.count >= meta.max && meta.max > 0 : false,
isLoading,
cancellationPending,
queue,
clearAll,
drainNext,
sendEntryNow,
sendAllNow,
editEntry,
removeEntry,
mergeEntry,
refetch: refetchBound,
};
}