Merge pull request #66454 from NousResearch/ethie/session-status-sync

refactor(desktop): derive working/attention session sets from $sessionStates
This commit is contained in:
brooklyn! 2026-07-17 14:46:23 -04:00 committed by GitHub
commit cf52edbb59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 549 additions and 372 deletions

View file

@ -93,11 +93,10 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds,
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states'
import {
type AppView,

View file

@ -15,8 +15,8 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'

View file

@ -3,7 +3,8 @@ import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'

View file

@ -28,18 +28,16 @@ import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { resetTileRuntimeBindings } from '@/store/session-states'
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)

View file

@ -5,7 +5,8 @@ import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'

View file

@ -2,8 +2,9 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
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 { $workingSessionIds } from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@ -32,6 +33,7 @@ function Harness({
describe('useBackgroundQueueDrain', () => {
beforeEach(() => {
vi.useRealTimers()
clearAllSessionStates()
})
afterEach(() => {
@ -39,7 +41,7 @@ describe('useBackgroundQueueDrain', () => {
vi.restoreAllMocks()
vi.useRealTimers()
$queuedPromptsBySession.set({})
$workingSessionIds.set([])
clearAllSessionStates()
})
it('drains an idle queued prompt for a non-selected background session', async () => {
@ -47,7 +49,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
$workingSessionIds.set([])
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
@ -68,7 +70,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
$workingSessionIds.set([])
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />)
@ -83,7 +85,8 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
$workingSessionIds.set(['stored-session-a'])
// Mark the session as working (busy) so the drain should skip it
publishSessionState('rt-session-a', { ...createClientSessionState('stored-session-a'), busy: true })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)

View file

@ -12,7 +12,7 @@ import {
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $workingSessionIds } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
import type { SubmitTextOptions } from './use-prompt-actions/utils'

View file

@ -15,9 +15,7 @@ import {
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
setCronSessions,
@ -29,6 +27,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own

View file

@ -6,24 +6,18 @@ import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$activeSessionId,
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setActiveSessionStoredId,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState } from '@/store/session-states'
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@ -103,33 +97,20 @@ export function useSessionStateCache({
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
if (existing) {
if (storedSessionId !== undefined) {
const previousStoredSessionId = existing.storedSessionId
existing.storedSessionId = storedSessionId
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
// state object rather than mutating in place — updateSessionState needs
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
const updated = { ...existing, storedSessionId }
sessionStateByRuntimeIdRef.current.set(sessionId, updated)
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
if (existing.busy) {
setSessionWorking(storedSessionId, true)
}
}
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
setSessionWorking(previousStoredSessionId, false)
// Auto-compression rotated the stored id on the active session. Signal
// the route-following effect in use-session-actions so the URL + selection
// re-anchor to the continuation id — otherwise the next send hits a stale
// stored→runtime mapping (getRuntimeIdForStoredSession returns null) and
// triggers a full thread reload via resumeStoredSession.
if (sessionId === $activeSessionId.get()) {
setActiveSessionStoredId(storedSessionId)
}
}
}
return existing
return sessionStateByRuntimeIdRef.current.get(sessionId)!
}
const created = createClientSessionState(storedSessionId ?? null)
@ -267,38 +248,14 @@ export function useSessionStateCache({
)
const updateSessionState = useCallback(
(
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => {
(sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, storedSessionId?: string | null) => {
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
// Publishing to $sessionStates automatically fires transition side-effects
// (watchdog, settle grace, unread marker, compression id rotation) inside
// publishSessionState — no manual transition call needed.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
// the stream goes silent.
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next
@ -318,30 +275,32 @@ export function useSessionStateCache({
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
}, [])
// When the store watchdog force-clears a stuck session (8 min of stream
// silence — a hung or looping turn that never delivered its terminal event),
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
// re-syncs `$busy` when the healed session is the one on screen.
useEffect(
() =>
onSessionWatchdogClear(storedSessionId => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
// Wire the watchdog's force-clear callback to our cache. When the watchdog
// fires (8 min of stream silence — a hung or looping turn that never
// delivered its terminal event), it calls this to clear the session's busy
// state. Clearing the sidebar dot alone would leave the composer wedged on
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
// → $workingSessionIds (computed) follows automatically, and
// syncSessionStateToView re-syncs $busy when the healed session is the one
// on screen.
useEffect(() => {
setWatchdogClearFn(runtimeId => {
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
if (!runtimeId || !state?.busy) {
return
}
if (!state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
}),
[updateSessionState]
)
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
})
return () => setWatchdogClearFn(null)
}, [updateSessionState])
return {
activeSessionIdRef,

View file

@ -3,8 +3,8 @@ import { atom } from 'nanostores'
import { queryClient } from '@/lib/query-client'
import { resetSessionsLimit } from '@/store/layout'
import {
$unreadFinishedSessionIds,
setActiveSessionId,
setAttentionSessionIds,
setCronSessions,
setFreshDraftReady,
setMessages,
@ -15,10 +15,9 @@ import {
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal,
setUnreadFinishedSessionIds,
setWorkingSessionIds
setSessionsTotal
} from '@/store/session'
import { clearAllSessionStates } from '@/store/session-states'
// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the
// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING
@ -45,9 +44,11 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessagingSessions([])
setMessagingPlatformTotals({})
setMessagingTruncated(false)
setWorkingSessionIds([])
setAttentionSessionIds([])
setUnreadFinishedSessionIds([])
// Clearing $sessionStates automatically clears $workingSessionIds and
// $attentionSessionIds (they're computed from it). $unreadFinishedSessionIds
// is separate (transient, not computable) so wipe it explicitly.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
setSessionsLoading(true)
resetSessionsLimit()

View file

@ -30,7 +30,7 @@ import {
import { readJson, writeJson } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
import { $activeSessionId, $selectedStoredSessionId } from './session'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds, setActiveSessionStoredId } from './session'
import { isSecondaryWindow } from './windows'
// ---------------------------------------------------------------------------
@ -39,12 +39,110 @@ import { isSecondaryWindow } from './windows'
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
/** Publish one session's state (immutable per-key — slices stay stable). */
// --- Watchdog: force-clears busy after 8 min of stream silence -------------
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
type WatchdogClearFn = (runtimeId: string) => void
let watchdogClearFn: WatchdogClearFn | null = null
export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
watchdogClearFn = fn
}
function armWatchdog(runtimeId: string) {
const existing = sessionWatchdogTimers.get(runtimeId)
if (existing) {clearTimeout(existing)}
sessionWatchdogTimers.set(
runtimeId,
setTimeout(() => {
sessionWatchdogTimers.delete(runtimeId)
watchdogClearFn?.(runtimeId)
}, SESSION_WATCHDOG_TIMEOUT_MS)
)
}
function clearWatchdog(runtimeId: string) {
const t = sessionWatchdogTimers.get(runtimeId)
if (t) {
clearTimeout(t)
sessionWatchdogTimers.delete(runtimeId)
}
}
// --- Settle grace: keeps a just-finished session in the sidebar merge set ---
const SESSION_SETTLE_GRACE_MS = 30 * 1000
const settledExpiry = new Map<string, number>()
function markSettled(storedId: string) {
settledExpiry.set(storedId, Date.now() + SESSION_SETTLE_GRACE_MS)
}
function clearSettled(storedId: string) {
settledExpiry.delete(storedId)
}
/** Stored ids whose turn ended within the grace window. Prunes expired. */
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
const live: string[] = []
for (const [id, expiry] of settledExpiry) {
if (expiry > now) {live.push(id)}
else {settledExpiry.delete(id)}
}
return live
}
// --- Transition detection (called automatically from publishSessionState) ---
function handleTransition(previous: ClientSessionState | null, next: ClientSessionState, runtimeId: string) {
// Compression id rotation: signal the route-follow effect.
if (previous?.storedSessionId && next.storedSessionId && previous.storedSessionId !== next.storedSessionId) {
if (runtimeId === $activeSessionId.get()) {setActiveSessionStoredId(next.storedSessionId)}
clearSettled(previous.storedSessionId)
}
// Watchdog: arm on any busy publish, disarm on idle.
if (next.busy) {armWatchdog(runtimeId)}
else {clearWatchdog(runtimeId)}
const storedId = next.storedSessionId
if (!storedId) {return}
const wasWorking = previous?.busy ?? false
if (next.busy && !wasWorking) {
clearSettled(storedId)
} else if (!next.busy && wasWorking) {
markSettled(storedId)
if (storedId !== $selectedStoredSessionId.get()) {
const cur = $unreadFinishedSessionIds.get()
if (!cur.includes(storedId)) {$unreadFinishedSessionIds.set([...cur, storedId])}
}
}
}
/** Publish one session's state. Automatically fires transition side-effects
* (watchdog arm/disarm, settle grace, unread marker, compression id rotation)
* by diffing previous vs next callers never need to manually call a
* transition handler. */
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
const prev = $sessionStates.get()[runtimeId] ?? null
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
handleTransition(prev, state, runtimeId)
}
export function dropSessionState(runtimeId: string) {
// Disarm the watchdog — a dropped runtime must not fire a stale clear later.
// Settle-grace entries are keyed by stored id and self-expire; leave them so
// a just-finished session's row survives merge eviction even if its tile or
// cached runtime is dropped in the meantime.
clearWatchdog(runtimeId)
const current = $sessionStates.get()
if (!(runtimeId in current)) {
@ -55,6 +153,38 @@ export function dropSessionState(runtimeId: string) {
$sessionStates.set(rest)
}
/** Drop every cached session state used on soft gateway-mode apply so the
* computed working / attention sets drain to empty alongside the session list.
* Also disarms every watchdog timer and drops all settle-grace entries: a
* wiped gateway's sessions must not fire stale clears or linger in the
* sidebar merge keep-set after the switch. */
export function clearAllSessionStates() {
for (const timer of sessionWatchdogTimers.values()) {
clearTimeout(timer)
}
sessionWatchdogTimers.clear()
settledExpiry.clear()
$sessionStates.set({})
}
// Derived per-session status sets. `$sessionStates` already holds `busy` and
// `needsInput` for every runtime session (written by updateSessionState); these
// are pure projections of it, not independently maintained atoms. This keeps the
// data flow one-directional: gateway event → cache → $sessionStates → computed
// views, eliminating the "projection atom out of sync with cache" bug class.
export const $workingSessionIds = computed($sessionStates, states =>
Object.values(states)
.filter(s => s.busy && s.storedSessionId)
.map(s => s.storedSessionId!)
)
export const $attentionSessionIds = computed($sessionStates, states =>
Object.values(states)
.filter(s => s.needsInput && s.storedSessionId)
.map(s => s.storedSessionId!)
)
// ---------------------------------------------------------------------------
// Session tiles.
// ---------------------------------------------------------------------------

View file

@ -1,59 +1,286 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $workingSessionIds, onSessionWatchdogClear, setSessionWorking, setWorkingSessionIds } from './session'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
import {
$attentionSessionIds,
$sessionStates,
$workingSessionIds,
clearAllSessionStates,
getRecentlySettledSessionIds,
publishSessionState,
setWatchdogClearFn
} from './session-states'
const WATCHDOG_MS = 8 * 60 * 1000
describe('session watchdog', () => {
function state(over: Partial<ClientSessionState> = {}): ClientSessionState {
return { ...createClientSessionState(null), storedSessionId: 's1', ...over }
}
describe('session status transitions', () => {
beforeEach(() => {
vi.useFakeTimers()
setWorkingSessionIds(() => [])
vi.setSystemTime(0)
// clearAllSessionStates also disarms watchdog timers + drops settle-grace
// entries, so no leftover state can leak in from a previous test.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
})
it('drops a stuck session and notifies listeners once the silence window elapses', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
it('adds a session to $workingSessionIds when busy transitions to true', () => {
const s = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
// idle → working
const next = { ...s, busy: true }
publishSessionState('rt1', next)
expect($workingSessionIds.get()).toContain('s1')
})
it('removes a session from $workingSessionIds when busy transitions to false', () => {
const s = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', s)
// Simulate the working state being set
const working = { ...s, busy: true }
publishSessionState('rt1', working)
setSessionWorking('s1', true)
expect($workingSessionIds.get()).toContain('s1')
// Now transition to idle
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($workingSessionIds.get()).not.toContain('s1')
})
it('adds a session to $attentionSessionIds when needsInput is true', () => {
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
const next = { ...s, needsInput: true }
publishSessionState('rt1', next)
expect($attentionSessionIds.get()).toContain('s1')
})
it('marks a background session unread when its turn finishes', () => {
$selectedStoredSessionId.set('other-session')
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
})
it('does NOT mark unread when the finishing session is the active one', () => {
$selectedStoredSessionId.set('s1')
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
$selectedStoredSessionId.set('other-session')
const idle = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('grants settle grace when a working session goes idle', () => {
$selectedStoredSessionId.set('other')
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
})
it('does not grant grace on idle→idle re-asserts', () => {
const idle = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears settle grace when the session goes busy again', () => {
$selectedStoredSessionId.set('other')
const working = state({ busy: true, storedSessionId: 's2' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
// New turn for the same session
const workingAgain = { ...idle, busy: true }
publishSessionState('rt1', workingAgain)
expect(getRecentlySettledSessionIds()).toEqual([])
})
})
describe('session watchdog', () => {
beforeEach(() => {
vi.useFakeTimers()
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
})
it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
// Wire a clear fn like use-session-state-cache does in the real app: the
// watchdog hands us the runtime id, we publish the busy:false state.
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => {
clearedRuntimeIds.push(runtimeId)
const current = $sessionStates.get()[runtimeId]
if (current) {
publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
}
})
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
expect($workingSessionIds.get()).toContain('s1')
// Watchdog fires after 8 min of silence → the wired clear fn runs and the
// computed working set drops the session. This asserts the timer→callback
// wiring, not just the projection.
vi.advanceTimersByTime(WATCHDOG_MS)
// Both the sidebar dot AND the busy-clearing signal fire — the contract
// that lets the composer recover from a hung/looping turn, not just the dot.
expect(clearedRuntimeIds).toEqual(['rt1'])
expect($workingSessionIds.get()).not.toContain('s1')
expect(cleared).toEqual(['s1'])
off()
setWatchdogClearFn(null)
})
it('never fires for a session that settles before the window', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
setSessionWorking('s2', true)
setSessionWorking('s2', false)
const working = state({ busy: true, storedSessionId: 's2' })
publishSessionState('rt2', working)
// Session settles before the watchdog window
const idle = { ...working, busy: false }
publishSessionState('rt2', idle)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
// The watchdog was disarmed — the clear fn never ran.
expect(clearedRuntimeIds).toEqual([])
expect($workingSessionIds.get()).not.toContain('s2')
off()
setWatchdogClearFn(null)
})
it('stops notifying after unsubscribe', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
off()
it('does not fire after clearAllSessionStates disarms every timer', () => {
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
clearAllSessionStates()
setSessionWorking('s3', true)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
expect(clearedRuntimeIds).toEqual([])
setWatchdogClearFn(null)
})
})
describe('computed $workingSessionIds', () => {
beforeEach(() => {
clearAllSessionStates()
})
afterEach(() => {
clearAllSessionStates()
})
it('is empty when no sessions are busy', () => {
expect($workingSessionIds.get()).toEqual([])
})
it('reflects sessions with busy=true and a storedSessionId', () => {
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
publishSessionState('rt2', state({ busy: false, storedSessionId: 's2' }))
publishSessionState('rt3', state({ busy: true, storedSessionId: null }))
expect($workingSessionIds.get()).toEqual(['s1'])
})
it('updates when session state changes', () => {
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
expect($workingSessionIds.get()).toEqual(['s1'])
publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
expect($workingSessionIds.get()).toEqual([])
})
})
describe('computed $attentionSessionIds', () => {
beforeEach(() => {
clearAllSessionStates()
})
afterEach(() => {
clearAllSessionStates()
})
it('reflects sessions with needsInput=true and a storedSessionId', () => {
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
publishSessionState('rt2', state({ needsInput: false, storedSessionId: 's2' }))
expect($attentionSessionIds.get()).toEqual(['s1'])
})
it('clears when $sessionStates is cleared', () => {
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
expect($attentionSessionIds.get()).toEqual(['s1'])
clearAllSessionStates()
expect($attentionSessionIds.get()).toEqual([])
})
})

View file

@ -1,24 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$selectedStoredSessionId,
$unreadFinishedSessionIds,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSelectedStoredSessionId,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
} from './session'
import {
$attentionSessionIds,
clearAllSessionStates,
getRecentlySettledSessionIds,
publishSessionState
} from './session-states'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@ -39,30 +43,32 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
...over
})
describe('setSessionAttention', () => {
it('adds and removes a session id without duplicating it', () => {
$attentionSessionIds.set([])
setSessionAttention('s1', true)
setSessionAttention('s1', true)
expect($attentionSessionIds.get()).toEqual(['s1'])
setSessionAttention('s2', true)
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
setSessionAttention('s1', false)
expect($attentionSessionIds.get()).toEqual(['s2'])
$attentionSessionIds.set([])
describe('computed $attentionSessionIds', () => {
beforeEach(() => {
clearAllSessionStates()
})
it('ignores empty ids and no-op clears', () => {
$attentionSessionIds.set([])
afterEach(() => {
clearAllSessionStates()
})
setSessionAttention(null, true)
setSessionAttention(undefined, true)
setSessionAttention('', true)
setSessionAttention('missing', false)
it('reflects sessions with needsInput=true and a storedSessionId', () => {
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
publishSessionState('rt2', { ...createClientSessionState('s2'), needsInput: false })
expect($attentionSessionIds.get()).toEqual(['s1'])
})
it('updates when needsInput changes', () => {
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
expect($attentionSessionIds.get()).toEqual(['s1'])
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: false })
expect($attentionSessionIds.get()).toEqual([])
})
it('ignores sessions without a storedSessionId', () => {
publishSessionState('rt1', { ...createClientSessionState(null), needsInput: true })
expect($attentionSessionIds.get()).toEqual([])
})
})
@ -242,25 +248,36 @@ describe('workspaceCwdForNewSession', () => {
})
})
function makeState(over: Partial<ClientSessionState> = {}): ClientSessionState {
return { ...createClientSessionState('s1'), ...over }
}
describe('getRecentlySettledSessionIds', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
// clearAllSessionStates also drops settle-grace entries + watchdog timers,
// so nothing leaks in from a previous test.
clearAllSessionStates()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
afterEach(() => {
vi.useRealTimers()
$workingSessionIds.set([])
// Drain anything left in the grace map so tests stay isolated.
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
void id
}
clearAllSessionStates()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
it('keeps a session for the grace window after its turn settles, then drops it', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// A turn starts then ends: the working→idle transition grants grace.
setSessionWorking('s1', true)
setSessionWorking('s1', false)
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Still inside the window.
@ -273,74 +290,87 @@ describe('getRecentlySettledSessionIds', () => {
})
it('does not grant grace when the session was never working (idle re-asserts)', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// updateSessionState re-asserts `false` for idle sessions on every tick;
// these must not pin an idle chat into the keep-set indefinitely.
setSessionWorking('idle', false)
setSessionWorking('idle', false)
const idle = makeState({ busy: false, storedSessionId: 'idle' })
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears the grace timer when the session goes busy again', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
const working = makeState({ busy: true, storedSessionId: 's2' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
setSessionWorking('s2', true)
setSessionWorking('s2', false)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
// A new turn for the same session is "working" again — drop it from the
// settled set so it's tracked as working, not recently-finished.
setSessionWorking('s2', true)
const workingAgain = { ...idle, busy: true }
publishSessionState('rt1', workingAgain)
expect(getRecentlySettledSessionIds()).toEqual([])
})
})
describe('unread finished sessions', () => {
beforeEach(() => {
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$workingSessionIds.set([])
setSelectedStoredSessionId(() => null)
$selectedStoredSessionId.set(null)
})
afterEach(() => {
$workingSessionIds.set([])
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
setSelectedStoredSessionId(() => null)
$selectedStoredSessionId.set(null)
})
it('marks a session unread when its turn finishes in the background', () => {
setSelectedStoredSessionId(() => 'other-session')
setSessionWorking('s1', true)
setSessionWorking('s1', false)
$selectedStoredSessionId.set('other-session')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
})
it('does NOT mark unread when the finishing session is the active one', () => {
setSelectedStoredSessionId(() => 's1')
setSessionWorking('s1', true)
setSessionWorking('s1', false)
$selectedStoredSessionId.set('s1')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
setSelectedStoredSessionId(() => 'other-session')
setSessionWorking('s1', false)
setSessionWorking('s1', false)
$selectedStoredSessionId.set('other-session')
const idle = makeState({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('clears unread when the user opens the session', () => {
setSelectedStoredSessionId(() => 'other')
setSessionWorking('s1', true)
setSessionWorking('s1', false)
$selectedStoredSessionId.set('other')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
setSelectedStoredSessionId(() => 's1')
setSelectedStoredSessionId('s1')
expect($unreadFinishedSessionIds.get()).toEqual([])
})
})

View file

@ -245,7 +245,6 @@ export const $messagingTruncated = atom<boolean>(false)
// one. Empty for single-profile users (fall back to $sessionsTotal).
export const $sessionProfileTotals = atom<Record<string, number>>({})
export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
// Reactive signal for when the active session's stored id rotates (auto-
@ -326,17 +325,20 @@ export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($mes
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredId = (next: Updater<string | null>) => updateAtom($activeSessionStoredId, next)
// Transient: a background session finished and the user hasn't opened it since.
// Written by session-states.ts (handleTransition), cleared here on session open.
export const $unreadFinishedSessionIds = atom<string[]>([])
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
updateAtom($selectedStoredSessionId, next)
// Opening a session clears its unread state — the user is now looking at it.
const id = $selectedStoredSessionId.get()
if (id && $unreadFinishedSessionIds.get().includes(id)) {
toggleMembership(setUnreadFinishedSessionIds, id, false)
$unreadFinishedSessionIds.set($unreadFinishedSessionIds.get().filter(x => x !== id))
}
}
@ -427,176 +429,3 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
// Watchdog tracking — when does a "working" session count as stuck?
// Long-running tool calls (LLM inference, long shell commands, web fetches)
// can take a few minutes legitimately. We allow 8 minutes of complete
// silence on the stream before clearing the working flag; in practice this
// catches gateway hangs and dropped streams without false-positive-clearing
// real long turns.
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Notified (with the stored session id) whenever the watchdog force-clears a
// stuck session. The session-state cache subscribes to also drop that session's
// busy/awaiting flags — clearing `$workingSessionIds` alone only removes the
// sidebar dot, leaving the composer stuck on "Thinking"/Stop for a hung or
// looping turn that never streamed its terminal event.
type SessionWatchdogListener = (storedSessionId: string) => void
const sessionWatchdogListeners = new Set<SessionWatchdogListener>()
export function onSessionWatchdogClear(listener: SessionWatchdogListener): () => void {
sessionWatchdogListeners.add(listener)
return () => void sessionWatchdogListeners.delete(listener)
}
function armSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
sessionWatchdogTimers.delete(sessionId)
// Re-check the latest state at fire-time. If the user already navigated
// away or the session genuinely finished, the timer is a no-op.
if ($workingSessionIds.get().includes(sessionId)) {
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
}
for (const listener of sessionWatchdogListeners) {
listener(sessionId)
}
}, SESSION_WATCHDOG_TIMEOUT_MS)
sessionWatchdogTimers.set(sessionId, timer)
}
function clearSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
sessionWatchdogTimers.delete(sessionId)
}
}
// A session's "working" flag clears the instant its turn ends, but the
// cross-profile aggregator (listSessions with min_messages=1) only sees the
// just-persisted first turn a beat later. The active chat is shielded from that
// race by sessionsToKeep(), but a brand-new session that finished *while you
// were viewing a different chat* is, at the next refresh, neither working,
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
// then click another session before the first reply lands.)
//
// To bridge that window we keep a session in the merge keep-set for a short
// grace period after its turn settles, giving the aggregator time to catch up.
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
// session (mergeSessionPage only revives rows still present in the in-memory
// list, which optimistic delete/archive already drops).
const SESSION_SETTLE_GRACE_MS = 30 * 1000
const settledSessionExpiry = new Map<string, number>()
function markSessionSettled(sessionId: string) {
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
}
function clearSessionSettled(sessionId: string) {
settledSessionExpiry.delete(sessionId)
}
/** Stored ids of sessions whose turn ended within the grace window. Prunes
* expired entries as it reads, so it stays bounded without a timer. */
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
const live: string[] = []
for (const [id, expiry] of settledSessionExpiry) {
if (expiry > now) {
live.push(id)
} else {
settledSessionExpiry.delete(id)
}
}
return live
}
/** Call when a streaming event for a session lands. Refreshes the watchdog
* so the session keeps its "working" status as long as data keeps coming. */
export function noteSessionActivity(sessionId: string | null | undefined) {
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
return
}
armSessionWatchdog(sessionId)
}
// Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
// the same array reference so subscribers don't churn).
const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
set(current => {
const present = current.includes(id)
if (on) {
return present ? current : [...current, id]
}
return present ? current.filter(x => x !== id) : current
})
// Stored session ids whose most recent turn finished while the user was
// looking at a different session. The sidebar renders a steady green dot for
// these so the user can tab back and find newly-completed work. Cleared on
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
export const $unreadFinishedSessionIds = atom<string[]>([])
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => updateAtom($unreadFinishedSessionIds, next)
// Stored session ids with a blocking prompt (clarify) waiting on the user.
// Separate from $workingSessionIds: a session can be "working" (turn running)
// AND need input. The sidebar row reads this for a persistent indicator that,
// unlike a toast, survives window blur / alt-tab.
export const $attentionSessionIds = atom<string[]>([])
export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
if (sessionId) {
toggleMembership(setAttentionSessionIds, sessionId, needsInput)
}
}
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
if (!sessionId) {
return
}
const wasWorking = $workingSessionIds.get().includes(sessionId)
toggleMembership(setWorkingSessionIds, sessionId, working)
// Bookend the watchdog: arm on enter, disarm on leave. A later
// noteSessionActivity() from a streaming event refreshes the timer.
if (working) {
clearSessionSettled(sessionId)
armSessionWatchdog(sessionId)
} else {
clearSessionWatchdog(sessionId)
// Only grant grace on a real working→idle transition (updateSessionState
// re-asserts `false` on every state tick, which must not keep extending the
// window). This keeps the just-finished session visible long enough for the
// aggregator to return its now-persisted row.
if (wasWorking) {
markSessionSettled(sessionId)
// Mark unread when a background session finishes — only if the user
// isn't currently viewing it. The active session's finish is seen live.
if (sessionId !== $selectedStoredSessionId.get()) {
toggleMembership(setUnreadFinishedSessionIds, sessionId, true)
}
}
}
}