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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user