mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
A turn that ends without a final `todo` update left the composer "Tasks N/M" panel pinned with its last item stuck pending/in_progress, and it survived restarts because the panel is read back from stored session history. Two coupled fixes (the first alone is undone by the second path): - Turn end: clear a still-active todo list on `message.complete` and on a terminal `error` (new `clearActiveSessionTodos` — active lists only; a finished list keeps its short linger so the last checkmark still lands). - Rehydration: `hydrateFromStoredSession` runs *after* a turn completes, so an "active" stored list is stale, not in-flight. It now restores only a *finished* list (via new `todosForHydration`) and drops anything still active — otherwise it re-pinned the panel right after the turn-end clear and resurrected it on every restart. Salvages #52996 (@0disoft): the fix shape (clearActiveSessionTodos on turn completion, preserving the finished-list linger) is carried forward and ported onto the current use-message-stream/ folder split (gateway-event.ts), then extended to the rehydration path per review. Co-authored-by: 0disoft <rodisoft1@gmail.com>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import type { TodoItem } from '@/lib/todos'
|
|
|
|
/**
|
|
* Live todo list per runtime session, rendered by the composer status stack
|
|
* (the inline transcript panel is gone). Fed from two places:
|
|
*
|
|
* - live `todo` tool events (use-message-stream)
|
|
* - stored-session hydration (desktop-controller) — but only when the list is
|
|
* still in flight, so reopening an old chat doesn't pin its finished plan
|
|
* above the composer forever.
|
|
*/
|
|
export const $todosBySession = atom<Record<string, TodoItem[]>>({})
|
|
|
|
export const todoListActive = (todos: readonly TodoItem[]) =>
|
|
todos.some(t => t.status === 'pending' || t.status === 'in_progress')
|
|
|
|
// Decide which todo list to restore when rehydrating a session from stored
|
|
// history. Rehydration runs *after* a turn completes, so an active list (last
|
|
// item still pending/in_progress) is stale — the turn ended without a final
|
|
// `todo` update — and must NOT be re-pinned (that would undo the turn-end
|
|
// clear and, because it's read back from history, resurrect on restart). Only
|
|
// a finished list is restored, so its short linger shows the last checkmark.
|
|
// Returns null when there's nothing to restore (caller should clear).
|
|
export function todosForHydration(todos: readonly TodoItem[] | null): TodoItem[] | null {
|
|
return todos && !todoListActive(todos) ? [...todos] : null
|
|
}
|
|
|
|
// Once a list finishes (every item completed/cancelled), the final state
|
|
// lingers just long enough to see the last checkmark land, then the group
|
|
// drops out of the stack on its own.
|
|
const FINISHED_LINGER_MS = 4_000
|
|
const clearTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
function cancelScheduledClear(sid: string) {
|
|
const timer = clearTimers.get(sid)
|
|
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
clearTimers.delete(sid)
|
|
}
|
|
}
|
|
|
|
export function setSessionTodos(sid: string, todos: TodoItem[]) {
|
|
if (!sid) {
|
|
return
|
|
}
|
|
|
|
cancelScheduledClear(sid)
|
|
$todosBySession.set({ ...$todosBySession.get(), [sid]: todos })
|
|
|
|
if (!todoListActive(todos)) {
|
|
clearTimers.set(
|
|
sid,
|
|
setTimeout(() => {
|
|
clearTimers.delete(sid)
|
|
clearSessionTodos(sid)
|
|
}, FINISHED_LINGER_MS)
|
|
)
|
|
}
|
|
}
|
|
|
|
export function clearSessionTodos(sid: string) {
|
|
cancelScheduledClear(sid)
|
|
|
|
const map = $todosBySession.get()
|
|
|
|
if (!(sid in map)) {
|
|
return
|
|
}
|
|
|
|
const { [sid]: _drop, ...rest } = map
|
|
$todosBySession.set(rest)
|
|
}
|
|
|
|
// Drop a still-active todo list (any pending/in_progress item) — used at turn
|
|
// end, when an unfinished list means the turn stopped without a final `todo`
|
|
// update, so the "Tasks N/M" panel would otherwise stay pinned above the
|
|
// composer forever. A finished list is left untouched so its short linger
|
|
// still shows the last checkmark landing.
|
|
export function clearActiveSessionTodos(sid: string) {
|
|
const todos = $todosBySession.get()[sid]
|
|
|
|
if (!todos || !todoListActive(todos)) {
|
|
return
|
|
}
|
|
|
|
clearSessionTodos(sid)
|
|
}
|