feat(desktop): composer queue — queue many, edit/delete/cancel-edit, Cursor-style
Press Enter while busy with a draft to queue it; with no draft to interrupt and send the next queued turn. Auto-drains one queued turn each time the session settles, same as Cursor. Queue persists across reloads so an interrupted-and-queued turn isn't lost on refresh. Each queued row supports edit-in-composer (with explicit Save/Cancel), send-now (↑), and delete. Drain skips only the entry currently being edited so the rest of the queue keeps flowing. Queue dequeue is transactional — an entry only leaves the queue after `prompt.submit` is accepted, so a rejected submit doesn't drop the turn. Also shrinks the `[interrupted]` marker to a muted one-liner and drops its assistant footer so it stops looking like a real reply.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { ComposerAttachment } from './composer'
|
||||
|
||||
export interface QueuedPromptEntry {
|
||||
id: string
|
||||
text: string
|
||||
attachments: ComposerAttachment[]
|
||||
queuedAt: number
|
||||
}
|
||||
|
||||
type QueueState = Record<string, QueuedPromptEntry[]>
|
||||
|
||||
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<QueueState>(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 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, [])
|
||||
}
|
||||
Reference in New Issue
Block a user