+
Editing queued turn in composer
+
+
+
+
+
void
+ onEdit: (entry: QueuedPromptEntry) => void
+ onSendNow: (id: string) => void
+}
+
+const entryPreview = (entry: QueuedPromptEntry) =>
+ entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
+
+export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
+ const [collapsed, setCollapsed] = useState(false)
+
+ if (entries.length === 0) return null
+
+ return (
+
+
+
+ {!collapsed && (
+
+ {entries.map(entry => {
+ const isEditing = editingId === entry.id
+ const attachmentsCount = entry.attachments.length
+
+ return (
+
+
+
+
{entryPreview(entry)}
+ {(attachmentsCount > 0 || isEditing) && (
+
+ {attachmentsCount > 0 && (
+
+ {attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
+
+ )}
+ {isEditing && (
+
+ Editing in composer
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts
index 71c601e396..524667e95f 100644
--- a/apps/desktop/src/app/chat/composer/types.ts
+++ b/apps/desktop/src/app/chat/composer/types.ts
@@ -1,4 +1,5 @@
import type { HermesGateway } from '@/hermes'
+import type { ComposerAttachment } from '@/store/composer'
import type { DroppedFile } from '../hooks/use-composer-actions'
@@ -33,9 +34,10 @@ export interface ChatBarProps {
maxRecordingSeconds?: number
state: ChatBarState
gateway?: HermesGateway | null
+ queueSessionKey?: string | null
sessionId?: string | null
cwd?: string | null
- onCancel: () => void
+ onCancel: () => Promise
| void
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise | boolean | void
@@ -45,7 +47,10 @@ export interface ChatBarProps {
onPickFolders?: () => void
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
- onSubmit: (value: string) => Promise | void
+ onSubmit: (
+ value: string,
+ options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
+ ) => Promise | boolean
onTranscribeAudio?: (audio: Blob) => Promise
}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 0afed13a1a..8786b7bb2a 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -20,6 +20,7 @@ import { ChevronDown } from '@/lib/icons'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
+import type { ComposerAttachment } from '@/store/composer'
import {
$activeSessionId,
$awaitingResponse,
@@ -51,7 +52,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
gateway: HermesGateway | null
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
- onCancel: () => void
+ onCancel: () => Promise | void
onAddContextRef: (refText: string, label?: string, detail?: string) => void
onAddUrl: (url: string) => void
onBranchInNewChat: (messageId: string) => void
@@ -63,7 +64,10 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
onPickFolders: () => void
onPickImages: () => void
onRemoveAttachment: (id: string) => void
- onSubmit: (text: string) => Promise | void
+ onSubmit: (
+ text: string,
+ options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
+ ) => Promise | boolean
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise
onReload: (parentId: string | null) => Promise
@@ -311,6 +315,7 @@ export function ChatView({
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
+ queueSessionKey={selectedSessionId || activeSessionId}
sessionId={activeSessionId}
state={chatBarState}
/>
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 3ee05772cb..f1794d844a 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -470,7 +470,7 @@ export function DesktopController() {
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
- onCancel={() => void cancelRun()}
+ onCancel={cancelRun}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
void removeSession(selectedStoredSessionId)
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index bee5f78f09..ebb1e7dd6e 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -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]
)
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts
index 926f934e69..f1685de244 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts
@@ -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])
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 6dfae16e7c..d0a039f0f1 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -95,6 +95,10 @@ function messageContentText(content: unknown): string {
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
+const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
+
+const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
+
function resetStickyState(state: StickyStateFlags) {
state.escapedFromLock = false
state.isAtBottom = true
@@ -368,6 +372,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
+ const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
if (isPlaceholder) {
return null
@@ -380,7 +385,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
data-slot="aui_assistant-message-root"
>
{hoistedTodos.length > 0 && }
@@ -401,7 +409,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
- {messageText.trim().length > 0 && (
+ {messageText.trim().length > 0 && !interruptedOnly && (
)}
diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts
new file mode 100644
index 0000000000..9f15232aec
--- /dev/null
+++ b/apps/desktop/src/store/composer-queue.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import type { ComposerAttachment } from './composer'
+import {
+ $queuedPromptsBySession,
+ clearQueuedPrompts,
+ dequeueQueuedPrompt,
+ enqueueQueuedPrompt,
+ getQueuedPrompts,
+ removeQueuedPrompt,
+ updateQueuedPrompt,
+ updateQueuedPromptText
+} from './composer-queue'
+
+const SESSION_KEY = 'session-abc'
+const QUEUE_STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
+
+function attachment(id: string, kind: ComposerAttachment['kind'] = 'file'): ComposerAttachment {
+ return {
+ id,
+ kind,
+ label: id,
+ refText: `@file:${id}`
+ }
+}
+
+describe('composer queue store', () => {
+ beforeEach(() => {
+ window.localStorage.removeItem(QUEUE_STORAGE_KEY)
+ $queuedPromptsBySession.set({})
+ })
+
+ it('queues prompts in FIFO order', () => {
+ enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
+ enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
+
+ expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('first')
+ expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('second')
+ expect(dequeueQueuedPrompt(SESSION_KEY)).toBeNull()
+ })
+
+ it('clones attachments when queueing', () => {
+ const source = [attachment('a-1')]
+ const queued = enqueueQueuedPrompt(SESSION_KEY, { attachments: source, text: 'check clones' })
+
+ expect(queued).not.toBeNull()
+ expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).toEqual(source[0])
+ expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0])
+ })
+
+ it('updates and removes queued entries by id', () => {
+ const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' })
+ const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' })
+
+ expect(first).not.toBeNull()
+ expect(second).not.toBeNull()
+
+ expect(updateQueuedPromptText(SESSION_KEY, first!.id, 'draft one edited')).toBe(true)
+ expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft one edited', 'draft two'])
+
+ expect(removeQueuedPrompt(SESSION_KEY, first!.id)).toBe(true)
+ expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
+ })
+
+ it('updates queued text and attachment snapshot', () => {
+ const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
+ const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
+
+ expect(first).not.toBeNull()
+ expect(
+ updateQueuedPrompt(SESSION_KEY, first!.id, {
+ attachments: editedAttachments,
+ text: 'edited text'
+ })
+ ).toBe(true)
+
+ const queue = getQueuedPrompts(SESSION_KEY)
+ expect(queue[0]?.text).toBe('edited text')
+ expect(queue[0]?.attachments).toEqual(editedAttachments)
+ expect(queue[0]?.attachments[0]).not.toBe(editedAttachments[0])
+ })
+
+ it('clears queue state for a session', () => {
+ enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('img-1', 'image')], text: 'queued' })
+
+ clearQueuedPrompts(SESSION_KEY)
+
+ expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
+ expect($queuedPromptsBySession.get()[SESSION_KEY]).toBeUndefined()
+ expect(window.localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull()
+ })
+
+ it('persists queue entries into local storage', () => {
+ enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'persist me' })
+
+ const raw = window.localStorage.getItem(QUEUE_STORAGE_KEY)
+ expect(raw).toBeTruthy()
+
+ const parsed = JSON.parse(String(raw)) as Record
+ expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
+ })
+})
diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts
new file mode 100644
index 0000000000..d2a3f228ff
--- /dev/null
+++ b/apps/desktop/src/store/composer-queue.ts
@@ -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
+
+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 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, [])
+}