mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(desktop): Stop parks the queue instead of firing the next queued prompt
Interrupting a busy turn with the Stop button (or Esc) settles the session to idle, and the edge-independent auto-drain immediately submits the head of the composer queue. The user pressed Stop to halt the agent, but it looks like Stop skipped the current turn and kept going — and the queued text is hard to find, since its only surface is the collapsed 'N queued' pill above the composer. The old userInterruptedRef latch (a23728dcc) fixed this but was removed in #40221 because it also suppressed the drain that send-now-while-busy depends on. This reintroduces the halt with source awareness instead of a blanket latch: - Explicit halts (Stop button, composer Esc, chat-focus Esc, the streaming message's hover Stop, runtime cancel) park the session's queue before interrupting. Parked queues are skipped by both auto-drain paths (mounted ChatBar + background drainer). - Interrupts that exist to advance the queue (send-now-while-busy) unpark first, so the settle drain they rely on still flows. - The park lifts on any renewed intent: resume, a manual drain (Enter on empty composer or the per-row send arrow), queueing a new prompt, or emptying the queue. It migrates with entries on a runtime re-key and is deliberately not persisted (a fresh process starts unparked). - The queue panel expands on park, switches to 'N Queued — paused' with a pause icon, and grows a Resume action, so the held prompts are visible instead of reading as vanished. Store contract, hook wiring, and background-drain coverage included; docs updated.
This commit is contained in:
parent
477c08b447
commit
a85df69c06
15 changed files with 459 additions and 21 deletions
|
|
@ -0,0 +1,130 @@
|
|||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$parkedQueueSessions,
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
isQueueParked,
|
||||
parkQueuedPrompts
|
||||
} from '@/store/composer-queue'
|
||||
|
||||
import type { QueueEditState } from '../composer-utils'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
import { useComposerQueue } from './use-composer-queue'
|
||||
|
||||
// The park ↔ drain contract at the hook level. The store tests pin the pure
|
||||
// pieces (shouldAutoDrain, park bookkeeping); these pin the wiring — the
|
||||
// auto-drain effect honoring the park, and send-now-while-busy lifting it so
|
||||
// the settle drain still flows (the regression that sank the old blanket
|
||||
// interrupt latch).
|
||||
|
||||
const SESSION_KEY = 'stored-session-queue-hook'
|
||||
|
||||
function renderQueueHook(overrides: { busy?: boolean; onCancel?: () => void } = {}) {
|
||||
const onSubmit = vi.fn<ChatBarProps['onSubmit']>(async () => true)
|
||||
const onCancel = overrides.onCancel ?? vi.fn()
|
||||
const queueEditRef: { current: QueueEditState | null } = { current: null }
|
||||
|
||||
const hook = renderHook(
|
||||
({ busy }: { busy: boolean }) =>
|
||||
useComposerQueue({
|
||||
activeQueueSessionKey: SESSION_KEY,
|
||||
attachments: [],
|
||||
busy,
|
||||
clearDraft: () => undefined,
|
||||
draftRef: { current: '' },
|
||||
focusInput: () => undefined,
|
||||
loadIntoComposer: () => undefined,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
queueEditRef,
|
||||
queueSessionKey: SESSION_KEY,
|
||||
sessionId: 'rt-session-queue-hook'
|
||||
}),
|
||||
{ initialProps: { busy: overrides.busy ?? false } }
|
||||
)
|
||||
|
||||
return { hook, onCancel, onSubmit }
|
||||
}
|
||||
|
||||
describe('useComposerQueue park integration', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$queuedPromptsBySession.set({})
|
||||
$parkedQueueSessions.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
$queuedPromptsBySession.set({})
|
||||
$parkedQueueSessions.set({})
|
||||
})
|
||||
|
||||
it('auto-drains an unparked queue once idle', async () => {
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'flows' })
|
||||
|
||||
const { onSubmit } = renderQueueHook()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('holds a parked queue at the idle settle (the Stop edge)', async () => {
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'halted' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
const { hook, onSubmit } = renderQueueHook({ busy: true })
|
||||
|
||||
// The Stop settle: busy flips false with the park in place.
|
||||
hook.rerender({ busy: false })
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drainNextQueued sends a parked entry and lifts the park (manual resume)', async () => {
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'resumed' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
const { hook, onSubmit } = renderQueueHook()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.drainNextQueued()
|
||||
})
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('sendQueuedNow while busy unparks so the settle drain flows (no stale latch)', async () => {
|
||||
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'send me now' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
const { hook, onCancel, onSubmit } = renderQueueHook({ busy: true })
|
||||
const target = getQueuedPrompts(SESSION_KEY).find(e => e.id !== first!.id)!
|
||||
|
||||
act(() => {
|
||||
hook.result.current.sendQueuedNow(target.id)
|
||||
})
|
||||
|
||||
// The interrupt fired and the park lifted — this interrupt exists to reach
|
||||
// the queue, not to halt it.
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
|
||||
// Turn settles → the promoted entry drains.
|
||||
hook.rerender({ busy: false })
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toBe('send me now')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -6,6 +7,7 @@ import { useSessionSlice } from '@/lib/use-session-slice'
|
|||
import { type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import {
|
||||
$parkedQueueSessions,
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrain,
|
||||
unparkQueuedPrompts,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
|
@ -69,6 +72,12 @@ export function useComposerQueue({
|
|||
// write; the keyed array does not).
|
||||
const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey)
|
||||
|
||||
// Parked = the user explicitly halted this session (Stop/Esc) while prompts
|
||||
// were queued. The map is tiny (only halted sessions) so a plain subscribe
|
||||
// is fine; the auto-drain effect below reads it as a gate.
|
||||
const parkedSessions = useStore($parkedQueueSessions)
|
||||
const queueParked = Boolean(activeQueueSessionKey && parkedSessions[activeQueueSessionKey])
|
||||
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
queueEditRef.current = queueEdit
|
||||
|
||||
|
|
@ -217,6 +226,11 @@ export function useComposerQueue({
|
|||
drainFailuresRef.current.delete(entry.id)
|
||||
removeQueuedPrompt(drainQueueSessionKey, entry.id)
|
||||
resetBrowseState(drainRuntimeSessionId)
|
||||
// A successful drain means the queue is flowing again — lift any park
|
||||
// so the remaining entries follow. Manual drains (Enter on an empty
|
||||
// composer, the per-row send arrow) are exactly the resume gestures a
|
||||
// parked queue waits for; the auto path only reaches here unparked.
|
||||
unparkQueuedPrompts(drainQueueSessionKey)
|
||||
|
||||
return true
|
||||
} finally {
|
||||
|
|
@ -247,7 +261,10 @@ export function useComposerQueue({
|
|||
// Promote to the head, then interrupt. The gateway always emits a
|
||||
// settle (message.complete + session.info running:false) when the
|
||||
// turn unwinds, and the busy→false auto-drain below sends this entry.
|
||||
// Unpark first: this interrupt exists to REACH the queue, so the
|
||||
// settle drain must flow — unlike a Stop/Esc halt, which parks.
|
||||
promoteQueuedPrompt(activeQueueSessionKey, id)
|
||||
unparkQueuedPrompts(activeQueueSessionKey)
|
||||
triggerHaptic('selection')
|
||||
void Promise.resolve(onCancel())
|
||||
|
||||
|
|
@ -268,7 +285,7 @@ export function useComposerQueue({
|
|||
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
|
||||
// drain lock serializes sends; a remount/reconnect resets the failure counts.
|
||||
const autoDrainNext = useCallback(() => {
|
||||
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
if (busy || queueParked || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +316,7 @@ export function useComposerQueue({
|
|||
}
|
||||
})
|
||||
.catch(onFail)
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queueParked, queuedPrompts, runDrain, t])
|
||||
|
||||
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
|
||||
// never churns, so a change there is a real session switch and must NOT
|
||||
|
|
@ -318,12 +335,13 @@ export function useComposerQueue({
|
|||
|
||||
// Queued turns flow whenever the session is idle — on the busy→false settle
|
||||
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
|
||||
// strand them. To cancel queued turns, the user deletes them from the panel.
|
||||
// strand them. A park (explicit Stop/Esc) is the one gate: those entries wait
|
||||
// for the user. To cancel queued turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
|
||||
if (shouldAutoDrain({ isBusy: busy, parked: queueParked, queueLength: queuedPrompts.length })) {
|
||||
autoDrainNext()
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
}, [autoDrainNext, busy, queueParked, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
|
|
@ -353,6 +371,7 @@ export function useComposerQueue({
|
|||
exitQueuedEdit,
|
||||
queueCurrentDraft,
|
||||
queueEdit,
|
||||
queueParked,
|
||||
queuedPrompts,
|
||||
sendQueuedNow,
|
||||
stepQueuedEdit
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { removeQueuedPrompt } from '@/store/composer-queue'
|
||||
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
|
@ -189,6 +189,7 @@ export function ChatBar({
|
|||
exitQueuedEdit,
|
||||
queueCurrentDraft,
|
||||
queueEdit,
|
||||
queueParked,
|
||||
queuedPrompts,
|
||||
sendQueuedNow,
|
||||
stepQueuedEdit
|
||||
|
|
@ -209,6 +210,20 @@ export function ChatBar({
|
|||
|
||||
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
|
||||
|
||||
// Halt vs. reach-the-queue: every interrupt lands on onCancel, but only the
|
||||
// gestures that MEAN "stop working" (Stop button, Esc) go through this
|
||||
// wrapper, which parks the queue first — an explicit halt must not roll
|
||||
// straight into the next queued prompt (that read as Stop not working; the
|
||||
// queued text also seemed to vanish, since the collapsed panel row was its
|
||||
// only trace). Interrupts that exist to advance the queue (send-now-while-
|
||||
// busy) call the raw onCancel and keep draining on settle. Parked entries
|
||||
// stay in the panel until resumed, sent, edited, or deleted.
|
||||
const haltRun = useCallback(() => {
|
||||
parkQueuedPrompts(activeQueueSessionKeyRef.current)
|
||||
|
||||
return onCancel()
|
||||
}, [activeQueueSessionKeyRef, onCancel])
|
||||
|
||||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
|
|
@ -235,7 +250,9 @@ export function ChatBar({
|
|||
focusInput,
|
||||
inputDisabled,
|
||||
loadIntoComposer,
|
||||
onCancel,
|
||||
// The submit engine's only cancel call is the Stop-button branch (busy +
|
||||
// empty composer) — an explicit halt, so it parks the queue.
|
||||
onCancel: haltRun,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
queueCurrentDraft,
|
||||
|
|
@ -621,11 +638,11 @@ export function ChatBar({
|
|||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
|
||||
// the turn is parked waiting on the user, where Esc must not discard the
|
||||
// pending prompt.
|
||||
// pending prompt. An explicit halt, so it parks the queue too.
|
||||
if (busy && !awaitingInput) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
void Promise.resolve(haltRun())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -662,7 +679,8 @@ export function ChatBar({
|
|||
useComposerBranch({ clearDraft, cwd, draftRef })
|
||||
|
||||
// Global Esc-to-cancel when the chat (not the composer input) has focus.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
|
||||
// Same explicit-halt semantics as the Stop button: park the queue.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel: haltRun, target: scope.target })
|
||||
|
||||
const {
|
||||
conversation,
|
||||
|
|
@ -894,7 +912,17 @@ export function ChatBar({
|
|||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onResume={() => {
|
||||
unparkQueuedPrompts(activeQueueSessionKey)
|
||||
|
||||
// Idle → kick the head immediately; busy → the settle drain
|
||||
// takes over now that the park is lifted.
|
||||
if (!busy) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
}}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
parked={queueParked}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,17 @@ interface QueuePanelProps {
|
|||
entries: QueuedPromptEntry[]
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (entry: QueuedPromptEntry) => void
|
||||
/** Lift a park (explicit Stop/Esc halt) and let the queue flow again. */
|
||||
onResume: () => void
|
||||
onSendNow: (id: string) => void
|
||||
/** True after an explicit halt: entries wait until resumed / sent / edited. */
|
||||
parked: boolean
|
||||
}
|
||||
|
||||
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
|
||||
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onResume, onSendNow, parked }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
|
|
@ -29,9 +33,36 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
|||
}
|
||||
|
||||
return (
|
||||
// Keyed on the park flag: StatusSection owns its collapse state from
|
||||
// defaultCollapsed, so remount on park/unpark. A Stop must EXPAND the
|
||||
// panel — the halted prompts' only presence is here, and leaving them
|
||||
// behind a collapsed "N queued" pill is how they read as vanished.
|
||||
<StatusSection
|
||||
icon={<Codicon className="text-muted-foreground/70" name="layers" size="0.8rem" />}
|
||||
label={c.queued(entries.length)}
|
||||
accessory={
|
||||
parked ? (
|
||||
<Tip label={c.queueResumeTip}>
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={onResume}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{c.queueResume}
|
||||
</Button>
|
||||
</Tip>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={!parked}
|
||||
icon={
|
||||
<Codicon
|
||||
className="text-muted-foreground/70"
|
||||
name={parked ? 'debug-pause' : 'layers'}
|
||||
size="0.8rem"
|
||||
/>
|
||||
}
|
||||
key={parked ? 'parked' : 'flowing'}
|
||||
label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)}
|
||||
>
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
|||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
|
|
@ -300,6 +300,17 @@ export function ChatView({
|
|||
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
|
||||
}, [queueSessionKey, selectedSessionId])
|
||||
|
||||
// Transcript-side stops (the streaming message's hover Stop, the runtime's
|
||||
// cancel) are explicit halts, same as the composer's Stop button: park any
|
||||
// queued turns so the interrupt doesn't roll straight into the next one.
|
||||
// ChatBar wraps its own onCancel internally — its send-now-while-busy path
|
||||
// needs the raw interrupt — so it still receives the unwrapped prop.
|
||||
const haltRun = useCallback(() => {
|
||||
parkQueuedPrompts(queueSessionKey || activeSessionId)
|
||||
|
||||
return onCancel()
|
||||
}, [activeSessionId, onCancel, queueSessionKey])
|
||||
|
||||
// A tile IS its session — no route involved, never "mismatched".
|
||||
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
|
@ -460,7 +471,7 @@ export function ChatView({
|
|||
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onCancel={haltRun}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
|
|
@ -478,7 +489,7 @@ export function ChatView({
|
|||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onCancel={haltRun}
|
||||
onDismissError={onDismissError}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import type { MutableRefObject } from 'react'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import {
|
||||
$parkedQueueSessions,
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
parkQueuedPrompts
|
||||
} from '@/store/composer-queue'
|
||||
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
|
||||
|
||||
import { useBackgroundQueueDrain } from './use-background-queue-drain'
|
||||
|
|
@ -41,6 +47,7 @@ describe('useBackgroundQueueDrain', () => {
|
|||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
$queuedPromptsBySession.set({})
|
||||
$parkedQueueSessions.set({})
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
|
|
@ -96,6 +103,25 @@ describe('useBackgroundQueueDrain', () => {
|
|||
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not drain a parked background session, even when idle', async () => {
|
||||
// A Stop in a tile parks that session's queue; when the user then focuses
|
||||
// another chat, THIS drainer takes over the tile's queue — it must honor
|
||||
// the park just like the mounted ChatBar drainer does.
|
||||
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
|
||||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'halted by stop', attachments: [] })
|
||||
parkQueuedPrompts('stored-session-a')
|
||||
clearAllSessionStates()
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
|
||||
expect(submitText).not.toHaveBeenCalled()
|
||||
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes a null runtime id so submitText can resume stale background sessions by stored id', async () => {
|
||||
const runtimeMap = { current: new Map<string, string>() }
|
||||
const submitText = vi.fn(async () => true)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef, useState } from
|
|||
import { useI18n } from '@/i18n'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import {
|
||||
$parkedQueueSessions,
|
||||
$queuedPromptsBySession,
|
||||
getQueuedPrompts,
|
||||
MAX_AUTO_DRAIN_ATTEMPTS,
|
||||
|
|
@ -43,6 +44,7 @@ export function useBackgroundQueueDrain({
|
|||
}: BackgroundQueueDrainOptions) {
|
||||
const { t } = useI18n()
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const parkedQueueSessions = useStore($parkedQueueSessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const submitTextRef = useRef(submitText)
|
||||
const drainingSessionIdsRef = useRef(new Set<string>())
|
||||
|
|
@ -157,7 +159,11 @@ export function useBackgroundQueueDrain({
|
|||
if (
|
||||
sessionKey === selectedStoredSessionId ||
|
||||
drainingSessionIdsRef.current.has(sessionKey) ||
|
||||
!shouldAutoDrain({ isBusy: working.has(sessionKey), queueLength: entries.length })
|
||||
!shouldAutoDrain({
|
||||
isBusy: working.has(sessionKey),
|
||||
parked: Boolean(parkedQueueSessions[sessionKey]),
|
||||
queueLength: entries.length
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -170,5 +176,13 @@ export function useBackgroundQueueDrain({
|
|||
|
||||
drainSessionQueue(sessionKey, entry)
|
||||
}
|
||||
}, [drainSessionQueue, enabled, queuedPromptsBySession, retryTick, selectedStoredSessionId, workingSessionIds])
|
||||
}, [
|
||||
drainSessionQueue,
|
||||
enabled,
|
||||
parkedQueueSessions,
|
||||
queuedPromptsBySession,
|
||||
retryTick,
|
||||
selectedStoredSessionId,
|
||||
workingSessionIds
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1798,6 +1798,7 @@ export const en: Translations = {
|
|||
urlHintPre: 'Include the full URL, e.g. ',
|
||||
attach: 'Attach',
|
||||
queued: count => `${count} Queued`,
|
||||
queuedPaused: count => `${count} Queued — paused`,
|
||||
attachmentOnly: 'Attachment-only turn',
|
||||
emptyTurn: 'Empty turn',
|
||||
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
||||
|
|
@ -1807,6 +1808,8 @@ export const en: Translations = {
|
|||
queueSendNext: 'Next',
|
||||
queueSend: 'Send',
|
||||
queueDelete: 'Delete',
|
||||
queueResume: 'Resume',
|
||||
queueResumeTip: 'Paused by Stop — resume sending the queued turns',
|
||||
queueStuckTitle: 'Queued message not sent',
|
||||
queueStuckBody: 'A queued turn kept failing to send. It is still in the queue — try sending it again.',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
|
|
|
|||
|
|
@ -1721,6 +1721,7 @@ export const ja = defineLocale({
|
|||
urlHintPre: '完全な URL を入力してください。例: ',
|
||||
attach: '添付',
|
||||
queued: count => `${count} 件キュー済み`,
|
||||
queuedPaused: count => `${count} 件キュー済み — 一時停止中`,
|
||||
attachmentOnly: '添付のみのターン',
|
||||
emptyTurn: '空のターン',
|
||||
attachments: count => `${count} 件の添付`,
|
||||
|
|
@ -1730,6 +1731,8 @@ export const ja = defineLocale({
|
|||
queueSendNext: '次に送信',
|
||||
queueSend: '送信',
|
||||
queueDelete: '削除',
|
||||
queueResume: '再開',
|
||||
queueResumeTip: '停止により一時停止中 — キュー済みターンの送信を再開します',
|
||||
queueStuckTitle: 'キュー内のメッセージを送信できません',
|
||||
queueStuckBody:
|
||||
'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
|
||||
|
|
|
|||
|
|
@ -1488,6 +1488,7 @@ export interface Translations {
|
|||
urlHintPre: string
|
||||
attach: string
|
||||
queued: (count: number) => string
|
||||
queuedPaused: (count: number) => string
|
||||
attachmentOnly: string
|
||||
emptyTurn: string
|
||||
attachments: (count: number) => string
|
||||
|
|
@ -1497,6 +1498,8 @@ export interface Translations {
|
|||
queueSendNext: string
|
||||
queueSend: string
|
||||
queueDelete: string
|
||||
queueResume: string
|
||||
queueResumeTip: string
|
||||
queueStuckTitle: string
|
||||
queueStuckBody: string
|
||||
previewUnavailable: string
|
||||
|
|
|
|||
|
|
@ -1669,6 +1669,7 @@ export const zhHant = defineLocale({
|
|||
urlHintPre: '請輸入完整 URL,例如 ',
|
||||
attach: '附加',
|
||||
queued: count => `${count} 個排隊中`,
|
||||
queuedPaused: count => `${count} 個排隊中 — 已暫停`,
|
||||
attachmentOnly: '僅附件回合',
|
||||
emptyTurn: '空回合',
|
||||
attachments: count => `${count} 個附件`,
|
||||
|
|
@ -1678,6 +1679,8 @@ export const zhHant = defineLocale({
|
|||
queueSendNext: '下一個',
|
||||
queueSend: '傳送',
|
||||
queueDelete: '刪除',
|
||||
queueResume: '繼續',
|
||||
queueResumeTip: '已被停止操作暫停 — 繼續傳送排隊的回合',
|
||||
queueStuckTitle: '佇列訊息未送出',
|
||||
queueStuckBody: '佇列中的對話多次傳送失敗。它仍在佇列中,請重試傳送。',
|
||||
previewUnavailable: '預覽不可用',
|
||||
|
|
|
|||
|
|
@ -1979,6 +1979,7 @@ export const zh: Translations = {
|
|||
urlHintPre: '请包含完整 URL,例如 ',
|
||||
attach: '附加',
|
||||
queued: count => `${count} 条排队`,
|
||||
queuedPaused: count => `${count} 条排队 — 已暂停`,
|
||||
attachmentOnly: '仅附件回合',
|
||||
emptyTurn: '空回合',
|
||||
attachments: count => `${count} 个附件`,
|
||||
|
|
@ -1988,6 +1989,8 @@ export const zh: Translations = {
|
|||
queueSendNext: '下一个',
|
||||
queueSend: '发送',
|
||||
queueDelete: '删除',
|
||||
queueResume: '继续',
|
||||
queueResumeTip: '已被停止操作暂停 — 继续发送排队的回合',
|
||||
queueStuckTitle: '排队消息未发送',
|
||||
queueStuckBody: '排队的对话多次发送失败。它仍在队列中,请重试发送。',
|
||||
previewUnavailable: '预览不可用',
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||
|
||||
import type { ComposerAttachment } from './composer'
|
||||
import {
|
||||
$parkedQueueSessions,
|
||||
$queuedPromptsBySession,
|
||||
clearQueuedPrompts,
|
||||
dequeueQueuedPrompt,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
isQueueParked,
|
||||
migrateQueuedPrompts,
|
||||
parkQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrain,
|
||||
unparkQueuedPrompts,
|
||||
updateQueuedPrompt,
|
||||
updateQueuedPromptText
|
||||
} from './composer-queue'
|
||||
|
|
@ -167,4 +171,79 @@ describe('shouldAutoDrain', () => {
|
|||
it('does not drain an empty queue', () => {
|
||||
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain a parked queue, even when idle', () => {
|
||||
// The Stop/Esc settle edge: busy just flipped false but the user asked to
|
||||
// HALT — the park must hold the head back until they resume.
|
||||
expect(shouldAutoDrain({ isBusy: false, parked: true, queueLength: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('drains again once the park is lifted', () => {
|
||||
expect(shouldAutoDrain({ isBusy: false, parked: false, queueLength: 1 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parked queue sessions', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
|
||||
$queuedPromptsBySession.set({})
|
||||
$parkedQueueSessions.set({})
|
||||
})
|
||||
|
||||
it('parks only sessions with queued entries', () => {
|
||||
expect(parkQueuedPrompts(SESSION_KEY)).toBe(false)
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
|
||||
|
||||
expect(parkQueuedPrompts(SESSION_KEY)).toBe(true)
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(true)
|
||||
})
|
||||
|
||||
it('unparks explicitly', () => {
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
unparkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('queueing a fresh prompt lifts the park', () => {
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'new intent' })
|
||||
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('emptying the queue drops the park', () => {
|
||||
const entry = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
|
||||
parkQueuedPrompts(SESSION_KEY)
|
||||
|
||||
removeQueuedPrompt(SESSION_KEY, entry!.id)
|
||||
|
||||
expect(isQueueParked(SESSION_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('a park travels with migrated entries', () => {
|
||||
// A backend bounce right after Stop re-keys the queue; shedding the park
|
||||
// there would auto-send the exact prompts the user just halted.
|
||||
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'held back' })
|
||||
parkQueuedPrompts('rt-old')
|
||||
|
||||
migrateQueuedPrompts('rt-old', 'rt-new')
|
||||
|
||||
expect(isQueueParked('rt-old')).toBe(false)
|
||||
expect(isQueueParked('rt-new')).toBe(true)
|
||||
})
|
||||
|
||||
it('migration without a park does not invent one', () => {
|
||||
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'flowing' })
|
||||
|
||||
migrateQueuedPrompts('rt-old', 'rt-new')
|
||||
|
||||
expect(isQueueParked('rt-new')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,12 +46,42 @@ const save = (state: QueueState) => {
|
|||
|
||||
export const $queuedPromptsBySession = atom<QueueState>(load())
|
||||
|
||||
/**
|
||||
* Sessions whose queue the user explicitly halted (Stop button / Esc). A parked
|
||||
* queue is skipped by both auto-drain paths until the user acts on it again —
|
||||
* resume, send-now, a manual drain, queueing a fresh prompt, or emptying the
|
||||
* queue all unpark. Deliberately in-memory only: a fresh app process starts
|
||||
* unparked, so restored-entry semantics stay a separate concern.
|
||||
*/
|
||||
export const $parkedQueueSessions = atom<Record<string, true>>({})
|
||||
|
||||
const setParked = (sid: string, parked: boolean) => {
|
||||
const current = $parkedQueueSessions.get()
|
||||
|
||||
if (Boolean(current[sid]) === parked) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
|
||||
if (parked) {
|
||||
next[sid] = true
|
||||
} else {
|
||||
delete next[sid]
|
||||
}
|
||||
|
||||
$parkedQueueSessions.set(next)
|
||||
}
|
||||
|
||||
const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
|
||||
const current = $queuedPromptsBySession.get()
|
||||
const next = { ...current }
|
||||
|
||||
if (queue.length === 0) {
|
||||
delete next[sid]
|
||||
// An empty queue has nothing to hold back — drop the park so it can't
|
||||
// linger as stale state and silently gate entries queued much later.
|
||||
setParked(sid, false)
|
||||
} else {
|
||||
next[sid] = queue
|
||||
}
|
||||
|
|
@ -96,6 +126,10 @@ export const enqueueQueuedPrompt = (
|
|||
}
|
||||
|
||||
writeSession(sid, [...queueFor(sid), entry])
|
||||
// Queueing a new prompt is fresh intent to keep the conversation moving —
|
||||
// a park from an earlier Stop must not hold this (or the entries ahead of
|
||||
// it) back.
|
||||
setParked(sid, false)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
|
@ -237,12 +271,55 @@ export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey:
|
|||
$queuedPromptsBySession.set(next)
|
||||
save(next)
|
||||
|
||||
// The park is a property of the entries the user halted — it re-homes with
|
||||
// them. Without this, a backend bounce right after Stop would shed the park
|
||||
// and auto-send the exact prompts the user just held back.
|
||||
if ($parkedQueueSessions.get()[from]) {
|
||||
setParked(from, false)
|
||||
setParked(to, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a session's queue after an explicit user halt (Stop / Esc): entries stay
|
||||
* visible in the panel but neither auto-drain path sends them. No-op for a
|
||||
* session with nothing queued — parking exists to hold back queued turns, and
|
||||
* a park with no queue would only linger as a stale gate.
|
||||
*/
|
||||
export const parkQueuedPrompts = (key: string | null | undefined): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid || queueFor(sid).length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
setParked(sid, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Lift a park (user resumed the queue). Safe to call for any session. */
|
||||
export const unparkQueuedPrompts = (key: string | null | undefined): void => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (sid) {
|
||||
setParked(sid, false)
|
||||
}
|
||||
}
|
||||
|
||||
export const isQueueParked = (key: string | null | undefined): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
return sid ? Boolean($parkedQueueSessions.get()[sid]) : false
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrain}. */
|
||||
export interface AutoDrainInput {
|
||||
isBusy: boolean
|
||||
/** The user explicitly halted this session's queue (Stop / Esc). */
|
||||
parked?: boolean
|
||||
queueLength: number
|
||||
}
|
||||
|
||||
|
|
@ -255,8 +332,16 @@ export interface AutoDrainInput {
|
|||
* busy ref to the current value, swallowing the settle edge — an edge-gated
|
||||
* drain would then strand the entry forever. The caller's drain lock
|
||||
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
|
||||
*
|
||||
* `parked` is the one deliberate exception: an explicit Stop/Esc is the user
|
||||
* saying HALT, and immediately firing the next queued prompt contradicts the
|
||||
* instruction they just gave. Parked entries stay in the panel until the user
|
||||
* resumes, sends, edits, or deletes them. Interrupts that exist to reach the
|
||||
* queue faster (send-now-while-busy) never park, so they keep draining through
|
||||
* this same gate.
|
||||
*/
|
||||
export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0
|
||||
export const shouldAutoDrain = ({ isBusy, parked, queueLength }: AutoDrainInput): boolean =>
|
||||
!isBusy && !parked && queueLength > 0
|
||||
|
||||
/** Auto-drain attempts for one entry before we stop retrying and toast. The
|
||||
* entry stays queued for a manual send; a remount/reconnect resets the count. */
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ The center of the app. You get:
|
|||
- **The same conversation history** as every other Hermes surface — sessions started here resume in the CLI/TUI and vice versa.
|
||||
- **Drag-and-drop files** anywhere in the chat area to attach them to your next message.
|
||||
- **A right-hand preview rail** — render web pages, files, and tool outputs side by side while you keep chatting.
|
||||
- **Composer history and queue editing** — press the up/down arrow keys in an empty composer to recall and reuse previous prompts, and edit messages you've queued up before they're sent.
|
||||
- **Composer history and queue editing** — press the up/down arrow keys in an empty composer to recall and reuse previous prompts, and edit messages you've queued up before they're sent. Pressing Stop (or Esc) while turns are queued pauses the queue and expands it above the composer; resume it from there, or send, edit, and delete individual entries.
|
||||
|
||||
#### Status bar
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue