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:
Brooklyn Nicholson
2026-05-13 09:19:04 -04:00
parent b6f2ff5136
commit ca2c3d4ab4
11 changed files with 695 additions and 35 deletions
+158
View File
@@ -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, [])
}