diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 010c6d67fc..7fa9255a9e 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button' import { triggerHaptic } from '@/lib/haptics' -import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from '@/lib/icons' +import { ArrowUp, AudioLines, Layers3, Loader2, Mic, MicOff, Square } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -31,6 +31,7 @@ interface ConversationProps { export function ComposerControls({ busy, + busyAction, canSubmit, conversation, disabled, @@ -40,6 +41,7 @@ export function ComposerControls({ onDictate }: { busy: boolean + busyAction: 'queue' | 'stop' canSubmit: boolean conversation: ConversationProps disabled: boolean @@ -74,12 +76,21 @@ export function ComposerControls({ ) : ( )} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ace13c58cb..db9935d389 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -13,6 +13,7 @@ import { } from 'react' import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' +import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' @@ -20,7 +21,19 @@ import { contextPath } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments, $composerDraft } from '@/store/composer' +import { + $composerAttachments, + $composerDraft, + clearComposerAttachments, + type ComposerAttachment +} from '@/store/composer' +import { + $queuedPromptsBySession, + enqueueQueuedPrompt, + removeQueuedPrompt, + type QueuedPromptEntry, + updateQueuedPrompt +} from '@/store/composer-queue' import { $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -41,6 +54,7 @@ import { renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +import { QueuePanel } from './queue-panel' import { SkinSlashPopover } from './skin-slash-popover' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' @@ -53,6 +67,15 @@ const COMPOSER_STACK_BREAKPOINT_PX = 320 const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' +interface QueueEditState { + attachments: ComposerAttachment[] + draft: string + entryId: string + sessionKey: string +} + +const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + export function ChatBar({ busy, cwd, @@ -60,6 +83,7 @@ export function ChatBar({ focusKey, gateway, maxRecordingSeconds = 120, + queueSessionKey, sessionId, state, onCancel, @@ -77,12 +101,17 @@ export function ChatBar({ const aui = useAui() const draft = useAuiState(s => s.composer.text) const attachments = useStore($composerAttachments) + const queuedPromptsBySession = useStore($queuedPromptsBySession) const scrolledUp = useStore($threadScrolledUp) + const activeQueueSessionKey = queueSessionKey || sessionId || null + const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : [] const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) const draftRef = useRef(draft) + const previousBusyRef = useRef(busy) + const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) @@ -91,6 +120,7 @@ export function ChatBar({ const [voiceConversationActive, setVoiceConversationActive] = useState(false) const [tight, setTight] = useState(false) const [dragActive, setDragActive] = useState(false) + const [queueEdit, setQueueEdit] = useState(null) const dragDepthRef = useRef(0) const lastSpokenIdRef = useRef(null) @@ -102,6 +132,8 @@ export function ChatBar({ const stacked = expanded || narrow || tight const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0 const canSubmit = busy || hasComposerPayload + const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null + const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything' @@ -463,6 +495,14 @@ export function ChatBar({ } const handleEditorKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { + event.preventDefault() + + if (!busy) void drainNextQueued() + + return + } + if (trigger && triggerItems.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() @@ -499,6 +539,13 @@ export function ChatBar({ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() + + if (!busy && !hasComposerPayload && queuedPrompts.length > 0) { + void drainNextQueued() + + return + } + submitDraft() } } @@ -635,10 +682,147 @@ export function ChatBar({ } } - const submitDraft = () => { - if (busy) { + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + draftRef.current = text + aui.composer().setText(text) + $composerAttachments.set(cloneAttachments(attachments)) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, text) + placeCaretEnd(editor) + } + } + + const beginQueuedEdit = (entry: QueuedPromptEntry) => { + if (!activeQueueSessionKey || queueEdit) return + + setQueueEdit({ + attachments: cloneAttachments($composerAttachments.get()), + draft: draftRef.current, + entryId: entry.id, + sessionKey: activeQueueSessionKey + }) + loadIntoComposer(entry.text, entry.attachments) + triggerHaptic('selection') + focusInput() + } + + const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { + if (!queueEdit) return false + + if (action === 'save') { + const text = draftRef.current + const next = cloneAttachments($composerAttachments.get()) + + if (!text.trim() && next.length === 0) return false + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) + triggerHaptic(saved ? 'success' : 'selection') + } else { triggerHaptic('cancel') - onCancel() + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + setQueueEdit(null) + focusInput() + + return true + } + + const queueCurrentDraft = useCallback(() => { + if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false + + clearDraft() + clearComposerAttachments() + triggerHaptic('selection') + + return true + }, [activeQueueSessionKey, attachments, draft]) + + // All queue drain paths share one lock + send-then-remove sequence. + // `pickEntry` lets each caller choose head, by-id, or skip-edited. + const runDrain = useCallback( + async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { + if (drainingQueueRef.current || !activeQueueSessionKey) return false + + const entry = pickEntry(queuedPrompts) + + if (!entry) return false + + drainingQueueRef.current = true + + try { + const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })) + + if (accepted === false) return false + + removeQueuedPrompt(activeQueueSessionKey, entry.id) + + return true + } finally { + drainingQueueRef.current = false + } + }, + [activeQueueSessionKey, onSubmit, queuedPrompts] + ) + + const drainNextQueued = useCallback( + () => + runDrain(entries => { + const skip = queueEdit?.entryId + + return skip ? entries.find(e => e.id !== skip) : entries[0] + }), + [queueEdit, runDrain] + ) + + const sendQueuedNow = useCallback( + (id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)), + [queueEdit, runDrain] + ) + + const interruptAndSendNextQueued = useCallback(async () => { + if (queuedPrompts.length === 0) return false + + await Promise.resolve(onCancel()) + + return drainNextQueued() + }, [drainNextQueued, onCancel, queuedPrompts.length]) + + // Auto-drain on busy → false (turn settled). + useEffect(() => { + const wasBusy = previousBusyRef.current + previousBusyRef.current = busy + + if (busy || !wasBusy || queuedPrompts.length === 0) return + + void drainNextQueued() + }, [busy, drainNextQueued, queuedPrompts.length]) + + // Clean up queue edit when its target disappears (session swap or external delete). + useEffect(() => { + if (!queueEdit) return + if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + setQueueEdit(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps + + const submitDraft = () => { + if (queueEdit) { + exitQueuedEdit('save') + } else if (busy) { + if (hasComposerPayload) queueCurrentDraft() + else if (queuedPrompts.length > 0) void interruptAndSendNextQueued() + else { + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + } else if (!hasComposerPayload && queuedPrompts.length > 0) { + void drainNextQueued() } else if (draft.trim() || attachments.length > 0) { const submitted = draft triggerHaptic('submit') @@ -742,6 +926,7 @@ export function ChatBar({ const controls = ( )} + {activeQueueSessionKey && queuedPrompts.length > 0 && ( +
+ { + if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { + exitQueuedEdit('cancel') + } + }} + onEdit={beginQueuedEdit} + onSendNow={id => void sendQueuedNow(id)} + /> +
+ )}
+ {queueEdit && editingQueuedPrompt && ( +
+
Editing queued turn in composer
+
+ + +
+
+ )} {attachments.length > 0 && }
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, []) +}