mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
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:
@@ -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({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={busy ? 'Stop' : 'Send'}
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 size={16} />
|
||||
) : (
|
||||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUp size={18} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(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<QueueEditState | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const lastSpokenIdRef = useRef<string | null>(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<HTMLDivElement>) => {
|
||||
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<boolean> => {
|
||||
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 = (
|
||||
<ComposerControls
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSubmit={canSubmit}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
@@ -824,6 +1009,22 @@ export function ChatBar({
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
<div className="relative z-6 mb-1 px-0.5">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
@@ -871,6 +1072,28 @@ export function ChatBar({
|
||||
>
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">Editing queued turn in composer</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('cancel')}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('save')}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowUp, ChevronDown, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
interface QueuePanelProps {
|
||||
busy: boolean
|
||||
editingId: null | string
|
||||
entries: QueuedPromptEntry[]
|
||||
onDelete: (id: string) => 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 (
|
||||
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')} size={14} />
|
||||
<span className="truncate">{entries.length} Queued</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1.5 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
Editing in composer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0 transition-opacity',
|
||||
isEditing
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
title="Edit queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Send queued turn now"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Delete queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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> | void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
@@ -45,7 +47,10 @@ export interface ChatBarProps {
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSubmit: (value: string) => Promise<void> | void
|
||||
onSubmit: (
|
||||
value: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
) => Promise<boolean> | boolean
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => void
|
||||
onCancel: () => Promise<void> | 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<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onPickFolders: () => void
|
||||
onPickImages: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onSubmit: (text: string) => Promise<void> | void
|
||||
onSubmit: (
|
||||
text: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
) => Promise<boolean> | boolean
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
@@ -311,6 +315,7 @@ export function ChatView({
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId || activeSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground"
|
||||
className={cn(
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground',
|
||||
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
|
||||
)}
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
@@ -401,7 +409,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && (
|
||||
{messageText.trim().length > 0 && !interruptedOnly && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
|
||||
@@ -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<string, { text: string }[]>
|
||||
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
|
||||
})
|
||||
})
|
||||
@@ -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, [])
|
||||
}
|
||||
Reference in New Issue
Block a user