diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 262c2ead987e..bced6c268f7d 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -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,
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 9b15d388222c..6ce7997edd58 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -14,8 +14,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'
diff --git a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
index 2d8a17eb52ba..9dc3dbc60536 100644
--- a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
@@ -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'
diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
index 2fdba29f5680..93ec2c5d7714 100644
--- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
+++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
@@ -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)
diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx
index 6b5f932d79db..eb8df9136c1c 100644
--- a/apps/desktop/src/app/session-switcher.tsx
+++ b/apps/desktop/src/app/session-switcher.tsx
@@ -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'
diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx
index e6cd960c142d..9f5589b816f4 100644
--- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx
@@ -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()
@@ -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()
@@ -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()
diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts
index 83d389e9767b..8d98df149313 100644
--- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts
+++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts
@@ -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'
diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
index 6e4cbb7cc021..f3c88be43aa0 100644
--- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
@@ -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
diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
index 75b535835acc..2faa1091c54f 100644
--- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
@@ -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,
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index e728d57d46f0..0d1414adad00 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -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()
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index 43e5e21978f8..2ecf22882fef 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -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>({})
-/** 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>()
+
+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()
+
+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.
// ---------------------------------------------------------------------------
diff --git a/apps/desktop/src/store/session-watchdog.test.ts b/apps/desktop/src/store/session-watchdog.test.ts
index 75be2f203ca7..b4b7ca52303b 100644
--- a/apps/desktop/src/store/session-watchdog.test.ts
+++ b/apps/desktop/src/store/session-watchdog.test.ts
@@ -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 {
+ 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([])
})
})
diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts
index 7ff12111e70d..a3c816c762f6 100644
--- a/apps/desktop/src/store/session.test.ts
+++ b/apps/desktop/src/store/session.test.ts
@@ -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 => ({
archived: false,
@@ -39,30 +43,32 @@ const session = (over: Partial): 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 {
+ 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([])
})
})
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index 449ed6055f58..97dc03c0e4ee 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -245,7 +245,6 @@ export const $messagingTruncated = atom(false)
// one. Empty for single-profile users (fall back to $sessionsTotal).
export const $sessionProfileTotals = atom>({})
export const $sessionsLoading = atom(true)
-export const $workingSessionIds = atom([])
export const $activeSessionId = atom(null)
export const $selectedStoredSessionId = atom(null)
// Reactive signal for when the active session's stored id rotates (auto-
@@ -326,17 +325,20 @@ export const setMessagingTruncated = (next: Updater) => updateAtom($mes
export const setSessionProfileTotals = (next: Updater>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next)
-export const setWorkingSessionIds = (next: Updater) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredId = (next: Updater) => 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([])
+
export const setSelectedStoredSessionId = (next: Updater) => {
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))
}
}
@@ -421,176 +423,3 @@ export const setIntroSeed = (next: Updater) => updateAtom($introSeed, ne
export const setContextSuggestions = (next: Updater) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater) => updateAtom($modelPickerOpen, next)
export const setSessionPickerOpen = (next: Updater) => 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>()
-
-// 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()
-
-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()
-
-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) => 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([])
-export const setUnreadFinishedSessionIds = (next: Updater) => 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([])
-export const setAttentionSessionIds = (next: Updater) => 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)
- }
- }
- }
-}