import { atom } from 'nanostores' import type { ComposerAttachment } from './composer' export interface QueuedPromptEntry { id: string text: string attachments: ComposerAttachment[] queuedAt: number } type QueueState = Record const STORAGE_KEY = 'hermes.desktop.composerQueue.v1' const load = (): QueueState => { if (typeof window === 'undefined') { return {} } try { const raw = window.localStorage.getItem(STORAGE_KEY) const parsed = raw ? JSON.parse(raw) : null return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as QueueState) : {} } catch { return {} } } const save = (state: QueueState) => { if (typeof window === 'undefined') { return } try { if (Object.keys(state).length === 0) { window.localStorage.removeItem(STORAGE_KEY) } else { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) } } catch { // best-effort: storage may be unavailable, queue still works in-memory } } export const $queuedPromptsBySession = atom(load()) const writeSession = (sid: string, queue: QueuedPromptEntry[]) => { const current = $queuedPromptsBySession.get() const next = { ...current } if (queue.length === 0) { delete next[sid] } else { next[sid] = queue } $queuedPromptsBySession.set(next) save(next) } const sidOf = (key: string | null | undefined): null | string => { const trimmed = key?.trim() return trimmed ? trimmed : null } const queueFor = (sid: string) => $queuedPromptsBySession.get()[sid] ?? [] const nextId = () => `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEntry[] => { const sid = sidOf(key) return sid ? queueFor(sid) : [] } export const enqueueQueuedPrompt = ( key: string | null | undefined, payload: { text: string; attachments: ComposerAttachment[] } ): null | QueuedPromptEntry => { const sid = sidOf(key) if (!sid) { return null } const entry: QueuedPromptEntry = { id: nextId(), text: payload.text, attachments: cloneAttachments(payload.attachments), queuedAt: Date.now() } writeSession(sid, [...queueFor(sid), entry]) return entry } export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => { const sid = sidOf(key) if (!sid) { return null } const [head, ...rest] = queueFor(sid) if (!head) { return null } writeSession(sid, rest) return head } export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => { const sid = sidOf(key) if (!sid) { return false } const queue = queueFor(sid) const next = queue.filter(e => e.id !== id) if (next.length === queue.length) { return false } writeSession(sid, next) return true } export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => { const sid = sidOf(key) if (!sid) { return false } const queue = queueFor(sid) const index = queue.findIndex(e => e.id === id) if (index <= 0) { return false } const entry = queue[index]! writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)]) return true } export const updateQueuedPrompt = ( key: string | null | undefined, id: string, update: { text: string; attachments?: ComposerAttachment[] } ): boolean => { const sid = sidOf(key) if (!sid) { return false } const queue = queueFor(sid) let changed = false const next = queue.map(entry => { if (entry.id !== id) { return entry } const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments if (entry.text === update.text && !update.attachments) { return entry } changed = true return { ...entry, text: update.text, attachments } }) if (!changed) { return false } writeSession(sid, next) return true } export const updateQueuedPromptText = (key: string | null | undefined, id: string, text: string): boolean => updateQueuedPrompt(key, id, { text }) export const clearQueuedPrompts = (key: string | null | undefined) => { const sid = sidOf(key) if (!sid || !(sid in $queuedPromptsBySession.get())) { return } writeSession(sid, []) } /** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */ export interface AutoDrainSettleInput { wasBusy: boolean isBusy: boolean queueLength: number } /** * Decide whether the composer should auto-drain the next queued prompt when a * turn settles (busy transitions true → false). * * Queued turns always advance once the session is idle again, whether the turn * finished naturally or the user interrupted it. Interrupting to reach a queued * message is the whole point of the queue, so we never suppress the drain. The * gateway guarantees a settle (message.complete + session.info running:false) * even after an interrupt, so this single edge reliably advances the queue. To * cancel queued turns the user deletes them from the panel. */ export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => { const { isBusy, queueLength, wasBusy } = params // Only react to a true → false transition; ignore steady state and entry. if (isBusy || !wasBusy) { return false } return queueLength > 0 }