diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 28df8b93e143..8f7e1420a57d 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 2bc61403586a..bf63369c4036 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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' 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 d32231bf84b2..67e8dfdbaa8c 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)) } } @@ -427,176 +429,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) - } - } - } -}