mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
* fix(desktop): make composer message queue reliable The queue felt 'dumb' because of three real bugs: 1. Drained-after-interrupt sends went silent. cancelRun sets interrupted:true and nothing reset it; submitPromptText's optimistic seed preserved it, and the message stream drops every delta while interrupted. So Send-now-while-busy and any interrupt+drain submitted the next turn into a muted session. Fix: a fresh submit is a new turn — seed interrupted:false. 2. Back-to-back queue drains stalled. The drain fires on the busy->false settle edge, but busyRef (synced from the busy store by a separate effect) can still read true on that same edge, so the drained send hit the busy guard, returned false, and the entry was never removed. Fix: fromQueue sends bypass the busyRef guard (the queue drain lock serializes them); the user path keeps the guard. 3. Double-enter-to-interrupt killed single non-queue turns. The hidden 450ms timer meant a natural double-tap after sending stopped the agent. Fix: empty Enter while busy is a no-op; interrupting is explicit — Stop button or Esc. Also: clean stop (no [interrupted] marker), Send-now works while busy (promote + interrupt + auto-drain), settle on the interrupted completion path. Adds regression tests and unblocks the prompt-actions suite by completing its stale @/hermes mock. * fix(desktop): float the queue panel as an overlay so the chat doesn't resize The queue list rendered in-flow inside the composer root, so its height fed --composer-measured-height (the composer rect drives the thread's bottom padding + last-message clearance). Queuing a message grew that rect and the whole chat visibly resized. Anchor the panel out of flow above the composer (absolute bottom-full, capped at 40vh with internal scroll). It no longer contributes to the measured height, so the thread layout stays put and the list overlays the (already faded) chat. Still collapsible via the panel's own disclosure header. * fix(desktop): queue panel collapsed by default + shared border with composer - Default the queue disclosure to collapsed (compact 'N queued' pill) instead of expanded. - Drop the gap and merge the panel into the composer: square bottom corners, no bottom border/radius, and overlap down by the Root's pt-2 (-mb-2) so the panel's borderless bottom lands on the composer surface's top border — one continuous bordered shape. * style(desktop): tighten queue panel padding * style(desktop): trim queue-ux comments to house style * style(desktop): drop 'Cursor' references from comments
239 lines
5.4 KiB
TypeScript
239 lines
5.4 KiB
TypeScript
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 promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
|
|
const sid = sidOf(key)
|
|
|
|
if (!sid) {
|
|
return false
|
|
}
|
|
|
|
const queue = queueFor(sid)
|
|
const index = queue.findIndex(e => e.id === id)
|
|
|
|
if (index <= 0) {
|
|
return false
|
|
}
|
|
|
|
const entry = queue[index]!
|
|
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
|
|
|
|
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, [])
|
|
}
|
|
|
|
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
|
export interface AutoDrainSettleInput {
|
|
wasBusy: boolean
|
|
isBusy: boolean
|
|
queueLength: number
|
|
}
|
|
|
|
/**
|
|
* Decide whether the composer should auto-drain the next queued prompt when a
|
|
* turn settles (busy transitions true → false).
|
|
*
|
|
* Queued turns always advance once the session is idle again, whether the turn
|
|
* finished naturally or the user interrupted it. Interrupting to reach a queued
|
|
* message is the whole point of the queue, so we never suppress the drain. The
|
|
* gateway guarantees a settle (message.complete + session.info running:false)
|
|
* even after an interrupt, so this single edge reliably advances the queue. To
|
|
* cancel queued turns the user deletes them from the panel.
|
|
*/
|
|
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
|
const { isBusy, queueLength, wasBusy } = params
|
|
|
|
// Only react to a true → false transition; ignore steady state and entry.
|
|
if (isBusy || !wasBusy) {
|
|
return false
|
|
}
|
|
|
|
return queueLength > 0
|
|
}
|