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
@@ -71,6 +71,11 @@ interface PromptActionsOptions {
) => ClientSessionState
}
interface SubmitTextOptions {
attachments?: ComposerAttachment[]
fromQueue?: boolean
}
function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
@@ -153,7 +158,12 @@ export function usePromptActions({
)
const syncImageAttachmentsForSubmit = useCallback(
async (sessionId: string, attachments: ComposerAttachment[]) => {
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
for (const attachment of images) {
@@ -173,22 +183,25 @@ export function usePromptActions({
const attachedPath = result.path || attachment.path
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
if (updateComposerAttachments) {
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
}
}
},
[requestGateway]
)
const submitPromptText = useCallback(
async (rawText: string) => {
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
@@ -200,7 +213,7 @@ export function usePromptActions({
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@@ -232,7 +245,7 @@ export function usePromptActions({
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
interrupted: state.interrupted
}),
selectedStoredSessionIdRef.current
)
@@ -278,7 +291,7 @@ export function usePromptActions({
releaseBusy()
notifyError(err, 'Session unavailable')
return
return false
}
if (!sessionId) {
@@ -286,16 +299,21 @@ export function usePromptActions({
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
return false
}
seedOptimistic(sessionId)
}
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await syncImageAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
await requestGateway('prompt.submit', { session_id: sessionId, text })
clearComposerAttachments()
if (usingComposerAttachments) clearComposerAttachments()
return true
} catch (err) {
releaseBusy()
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
@@ -303,10 +321,11 @@ export function usePromptActions({
if (isProviderSetupError(err)) {
requestDesktopOnboarding('Add a provider credential before sending your first message.')
return
return false
}
notifyError(err, 'Prompt failed')
return false
}
},
[
@@ -477,18 +496,18 @@ export function usePromptActions({
)
const submitText = useCallback(
async (rawText: string) => {
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const attachments = options?.attachments ?? $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
triggerHaptic('selection')
await executeSlashCommand(visibleText)
return
return true
}
await submitPromptText(rawText)
return await submitPromptText(rawText, options)
},
[executeSlashCommand, submitPromptText]
)
@@ -7,6 +7,7 @@ import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-me
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -649,6 +650,11 @@ export function useSessionActions({
}
await deleteSession(storedSessionId)
clearQueuedPrompts(storedSessionId)
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])