mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
refactor(desktop): derive working/attention session sets from $sessionStates
$workingSessionIds and $attentionSessionIds were independently maintained atoms that updateSessionState had to manually keep in sync with the session cache (paired setSessionWorking/setSessionAttention calls, plus a rotation special-case in ensureSessionState). Make them computed() projections of $sessionStates instead, so the data flow is one-directional: gateway event → cache → $sessionStates → computed views. Transition side-effects (watchdog arm/disarm, settle grace, unread marker, compression id rotation signal) move into handleTransition, fired from publishSessionState by diffing previous vs next — one choke point instead of per-callsite bookkeeping. The watchdog's force-clear reaches the cache through setWatchdogClearFn rather than a listener set. Also: - clearAllSessionStates disarms all watchdog timers and drops settle-grace entries so a gateway switch can't leak stale timers or keep-set rows - dropSessionState disarms the dropped runtime's watchdog timer - watchdog tests now exercise the real timer→callback wiring instead of manually simulating the clear
This commit is contained in:
parent
594308d4bb
commit
a75a8eda72
14 changed files with 549 additions and 372 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
|
|||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $workingSessionIds } from '@/store/session'
|
||||
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
|
||||
|
||||
import { useBackgroundQueueDrain } from './use-background-queue-drain'
|
||||
import type { SubmitTextOptions } from './use-prompt-actions/utils'
|
||||
|
|
@ -32,6 +33,7 @@ function Harness({
|
|||
describe('useBackgroundQueueDrain', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -39,7 +41,7 @@ describe('useBackgroundQueueDrain', () => {
|
|||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
$queuedPromptsBySession.set({})
|
||||
$workingSessionIds.set([])
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('drains an idle queued prompt for a non-selected background session', async () => {
|
||||
|
|
@ -47,7 +49,7 @@ describe('useBackgroundQueueDrain', () => {
|
|||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
|
||||
$workingSessionIds.set([])
|
||||
clearAllSessionStates()
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
|
|
@ -68,7 +70,7 @@ describe('useBackgroundQueueDrain', () => {
|
|||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
|
||||
$workingSessionIds.set([])
|
||||
clearAllSessionStates()
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />)
|
||||
|
||||
|
|
@ -83,7 +85,8 @@ describe('useBackgroundQueueDrain', () => {
|
|||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
|
||||
$workingSessionIds.set(['stored-session-a'])
|
||||
// Mark the session as working (busy) so the drain should skip it
|
||||
publishSessionState('rt-session-a', { ...createClientSessionState('stored-session-a'), busy: true })
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
import { readJson, writeJson } from '@/lib/storage'
|
||||
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds, setActiveSessionStoredId } from './session'
|
||||
import { isSecondaryWindow } from './windows'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -39,12 +39,110 @@ import { isSecondaryWindow } from './windows'
|
|||
|
||||
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
|
||||
|
||||
/** Publish one session's state (immutable per-key — slices stay stable). */
|
||||
// --- Watchdog: force-clears busy after 8 min of stream silence -------------
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
type WatchdogClearFn = (runtimeId: string) => void
|
||||
let watchdogClearFn: WatchdogClearFn | null = null
|
||||
|
||||
export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
|
||||
watchdogClearFn = fn
|
||||
}
|
||||
|
||||
function armWatchdog(runtimeId: string) {
|
||||
const existing = sessionWatchdogTimers.get(runtimeId)
|
||||
|
||||
if (existing) {clearTimeout(existing)}
|
||||
sessionWatchdogTimers.set(
|
||||
runtimeId,
|
||||
setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(runtimeId)
|
||||
watchdogClearFn?.(runtimeId)
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function clearWatchdog(runtimeId: string) {
|
||||
const t = sessionWatchdogTimers.get(runtimeId)
|
||||
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
sessionWatchdogTimers.delete(runtimeId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settle grace: keeps a just-finished session in the sidebar merge set ---
|
||||
const SESSION_SETTLE_GRACE_MS = 30 * 1000
|
||||
const settledExpiry = new Map<string, number>()
|
||||
|
||||
function markSettled(storedId: string) {
|
||||
settledExpiry.set(storedId, Date.now() + SESSION_SETTLE_GRACE_MS)
|
||||
}
|
||||
|
||||
function clearSettled(storedId: string) {
|
||||
settledExpiry.delete(storedId)
|
||||
}
|
||||
|
||||
/** Stored ids whose turn ended within the grace window. Prunes expired. */
|
||||
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
|
||||
const live: string[] = []
|
||||
|
||||
for (const [id, expiry] of settledExpiry) {
|
||||
if (expiry > now) {live.push(id)}
|
||||
else {settledExpiry.delete(id)}
|
||||
}
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
// --- Transition detection (called automatically from publishSessionState) ---
|
||||
function handleTransition(previous: ClientSessionState | null, next: ClientSessionState, runtimeId: string) {
|
||||
// Compression id rotation: signal the route-follow effect.
|
||||
if (previous?.storedSessionId && next.storedSessionId && previous.storedSessionId !== next.storedSessionId) {
|
||||
if (runtimeId === $activeSessionId.get()) {setActiveSessionStoredId(next.storedSessionId)}
|
||||
clearSettled(previous.storedSessionId)
|
||||
}
|
||||
|
||||
// Watchdog: arm on any busy publish, disarm on idle.
|
||||
if (next.busy) {armWatchdog(runtimeId)}
|
||||
else {clearWatchdog(runtimeId)}
|
||||
|
||||
const storedId = next.storedSessionId
|
||||
|
||||
if (!storedId) {return}
|
||||
const wasWorking = previous?.busy ?? false
|
||||
|
||||
if (next.busy && !wasWorking) {
|
||||
clearSettled(storedId)
|
||||
} else if (!next.busy && wasWorking) {
|
||||
markSettled(storedId)
|
||||
|
||||
if (storedId !== $selectedStoredSessionId.get()) {
|
||||
const cur = $unreadFinishedSessionIds.get()
|
||||
|
||||
if (!cur.includes(storedId)) {$unreadFinishedSessionIds.set([...cur, storedId])}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish one session's state. Automatically fires transition side-effects
|
||||
* (watchdog arm/disarm, settle grace, unread marker, compression id rotation)
|
||||
* by diffing previous vs next — callers never need to manually call a
|
||||
* transition handler. */
|
||||
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
|
||||
const prev = $sessionStates.get()[runtimeId] ?? null
|
||||
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
|
||||
handleTransition(prev, state, runtimeId)
|
||||
}
|
||||
|
||||
export function dropSessionState(runtimeId: string) {
|
||||
// Disarm the watchdog — a dropped runtime must not fire a stale clear later.
|
||||
// Settle-grace entries are keyed by stored id and self-expire; leave them so
|
||||
// a just-finished session's row survives merge eviction even if its tile or
|
||||
// cached runtime is dropped in the meantime.
|
||||
clearWatchdog(runtimeId)
|
||||
|
||||
const current = $sessionStates.get()
|
||||
|
||||
if (!(runtimeId in current)) {
|
||||
|
|
@ -55,6 +153,38 @@ export function dropSessionState(runtimeId: string) {
|
|||
$sessionStates.set(rest)
|
||||
}
|
||||
|
||||
/** Drop every cached session state — used on soft gateway-mode apply so the
|
||||
* computed working / attention sets drain to empty alongside the session list.
|
||||
* Also disarms every watchdog timer and drops all settle-grace entries: a
|
||||
* wiped gateway's sessions must not fire stale clears or linger in the
|
||||
* sidebar merge keep-set after the switch. */
|
||||
export function clearAllSessionStates() {
|
||||
for (const timer of sessionWatchdogTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
sessionWatchdogTimers.clear()
|
||||
settledExpiry.clear()
|
||||
$sessionStates.set({})
|
||||
}
|
||||
|
||||
// Derived per-session status sets. `$sessionStates` already holds `busy` and
|
||||
// `needsInput` for every runtime session (written by updateSessionState); these
|
||||
// are pure projections of it, not independently maintained atoms. This keeps the
|
||||
// data flow one-directional: gateway event → cache → $sessionStates → computed
|
||||
// views, eliminating the "projection atom out of sync with cache" bug class.
|
||||
export const $workingSessionIds = computed($sessionStates, states =>
|
||||
Object.values(states)
|
||||
.filter(s => s.busy && s.storedSessionId)
|
||||
.map(s => s.storedSessionId!)
|
||||
)
|
||||
|
||||
export const $attentionSessionIds = computed($sessionStates, states =>
|
||||
Object.values(states)
|
||||
.filter(s => s.needsInput && s.storedSessionId)
|
||||
.map(s => s.storedSessionId!)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session tiles.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,59 +1,286 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $workingSessionIds, onSessionWatchdogClear, setSessionWorking, setWorkingSessionIds } from './session'
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$sessionStates,
|
||||
$workingSessionIds,
|
||||
clearAllSessionStates,
|
||||
getRecentlySettledSessionIds,
|
||||
publishSessionState,
|
||||
setWatchdogClearFn
|
||||
} from './session-states'
|
||||
|
||||
const WATCHDOG_MS = 8 * 60 * 1000
|
||||
|
||||
describe('session watchdog', () => {
|
||||
function state(over: Partial<ClientSessionState> = {}): ClientSessionState {
|
||||
return { ...createClientSessionState(null), storedSessionId: 's1', ...over }
|
||||
}
|
||||
|
||||
describe('session status transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setWorkingSessionIds(() => [])
|
||||
vi.setSystemTime(0)
|
||||
// clearAllSessionStates also disarms watchdog timers + drops settle-grace
|
||||
// entries, so no leftover state can leak in from a previous test.
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('drops a stuck session and notifies listeners once the silence window elapses', () => {
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
it('adds a session to $workingSessionIds when busy transitions to true', () => {
|
||||
const s = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
// idle → working
|
||||
const next = { ...s, busy: true }
|
||||
publishSessionState('rt1', next)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('removes a session from $workingSessionIds when busy transitions to false', () => {
|
||||
const s = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
// Simulate the working state being set
|
||||
const working = { ...s, busy: true }
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
setSessionWorking('s1', true)
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Now transition to idle
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
})
|
||||
|
||||
it('adds a session to $attentionSessionIds when needsInput is true', () => {
|
||||
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
const next = { ...s, needsInput: true }
|
||||
publishSessionState('rt1', next)
|
||||
|
||||
expect($attentionSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('marks a background session unread when its turn finishes', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
$selectedStoredSessionId.set('s1')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('grants settle grace when a working session goes idle', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does not grant grace on idle→idle re-asserts', () => {
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears settle grace when the session goes busy again', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// New turn for the same session
|
||||
const workingAgain = { ...idle, busy: true }
|
||||
publishSessionState('rt1', workingAgain)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session watchdog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
|
||||
// Wire a clear fn like use-session-state-cache does in the real app: the
|
||||
// watchdog hands us the runtime id, we publish the busy:false state.
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => {
|
||||
clearedRuntimeIds.push(runtimeId)
|
||||
const current = $sessionStates.get()[runtimeId]
|
||||
|
||||
if (current) {
|
||||
publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
|
||||
}
|
||||
})
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Watchdog fires after 8 min of silence → the wired clear fn runs and the
|
||||
// computed working set drops the session. This asserts the timer→callback
|
||||
// wiring, not just the projection.
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
// Both the sidebar dot AND the busy-clearing signal fire — the contract
|
||||
// that lets the composer recover from a hung/looping turn, not just the dot.
|
||||
expect(clearedRuntimeIds).toEqual(['rt1'])
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
expect(cleared).toEqual(['s1'])
|
||||
|
||||
off()
|
||||
setWatchdogClearFn(null)
|
||||
})
|
||||
|
||||
it('never fires for a session that settles before the window', () => {
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
|
||||
setSessionWorking('s2', true)
|
||||
setSessionWorking('s2', false)
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt2', working)
|
||||
|
||||
// Session settles before the watchdog window
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt2', idle)
|
||||
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(cleared).toEqual([])
|
||||
// The watchdog was disarmed — the clear fn never ran.
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
expect($workingSessionIds.get()).not.toContain('s2')
|
||||
|
||||
off()
|
||||
setWatchdogClearFn(null)
|
||||
})
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
off()
|
||||
it('does not fire after clearAllSessionStates disarms every timer', () => {
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
clearAllSessionStates()
|
||||
|
||||
setSessionWorking('s3', true)
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(cleared).toEqual([])
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed $workingSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('is empty when no sessions are busy', () => {
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('reflects sessions with busy=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
publishSessionState('rt2', state({ busy: false, storedSessionId: 's2' }))
|
||||
publishSessionState('rt3', state({ busy: true, storedSessionId: null }))
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('updates when session state changes', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
expect($workingSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed $attentionSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('reflects sessions with needsInput=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
|
||||
publishSessionState('rt2', state({ needsInput: false, storedSessionId: 's2' }))
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('clears when $sessionStates is cleared', () => {
|
||||
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
clearAllSessionStates()
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$unreadFinishedSessionIds,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
} from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
clearAllSessionStates,
|
||||
getRecentlySettledSessionIds,
|
||||
publishSessionState
|
||||
} from './session-states'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
|
|
@ -39,30 +43,32 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
|||
...over
|
||||
})
|
||||
|
||||
describe('setSessionAttention', () => {
|
||||
it('adds and removes a session id without duplicating it', () => {
|
||||
$attentionSessionIds.set([])
|
||||
|
||||
setSessionAttention('s1', true)
|
||||
setSessionAttention('s1', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
setSessionAttention('s2', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
|
||||
|
||||
setSessionAttention('s1', false)
|
||||
expect($attentionSessionIds.get()).toEqual(['s2'])
|
||||
|
||||
$attentionSessionIds.set([])
|
||||
describe('computed $attentionSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('ignores empty ids and no-op clears', () => {
|
||||
$attentionSessionIds.set([])
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
setSessionAttention(null, true)
|
||||
setSessionAttention(undefined, true)
|
||||
setSessionAttention('', true)
|
||||
setSessionAttention('missing', false)
|
||||
it('reflects sessions with needsInput=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
|
||||
publishSessionState('rt2', { ...createClientSessionState('s2'), needsInput: false })
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('updates when needsInput changes', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: false })
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores sessions without a storedSessionId', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState(null), needsInput: true })
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -242,25 +248,36 @@ describe('workspaceCwdForNewSession', () => {
|
|||
})
|
||||
})
|
||||
|
||||
function makeState(over: Partial<ClientSessionState> = {}): ClientSessionState {
|
||||
return { ...createClientSessionState('s1'), ...over }
|
||||
}
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
// clearAllSessionStates also drops settle-grace entries + watchdog timers,
|
||||
// so nothing leaks in from a previous test.
|
||||
clearAllSessionStates()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// Drain anything left in the grace map so tests stay isolated.
|
||||
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
|
||||
void id
|
||||
}
|
||||
clearAllSessionStates()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
it('keeps a session for the grace window after its turn settles, then drops it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// A turn starts then ends: the working→idle transition grants grace.
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
|
||||
// Still inside the window.
|
||||
|
|
@ -273,74 +290,87 @@ describe('getRecentlySettledSessionIds', () => {
|
|||
})
|
||||
|
||||
it('does not grant grace when the session was never working (idle re-asserts)', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// updateSessionState re-asserts `false` for idle sessions on every tick;
|
||||
// these must not pin an idle chat into the keep-set indefinitely.
|
||||
setSessionWorking('idle', false)
|
||||
setSessionWorking('idle', false)
|
||||
const idle = makeState({ busy: false, storedSessionId: 'idle' })
|
||||
publishSessionState('rt1', idle)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the grace timer when the session goes busy again', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
const working = makeState({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSessionWorking('s2', true)
|
||||
setSessionWorking('s2', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// A new turn for the same session is "working" again — drop it from the
|
||||
// settled set so it's tracked as working, not recently-finished.
|
||||
setSessionWorking('s2', true)
|
||||
const workingAgain = { ...idle, busy: true }
|
||||
publishSessionState('rt1', workingAgain)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unread finished sessions', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$workingSessionIds.set([])
|
||||
setSelectedStoredSessionId(() => null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$workingSessionIds.set([])
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
setSelectedStoredSessionId(() => null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
})
|
||||
|
||||
it('marks a session unread when its turn finishes in the background', () => {
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
$selectedStoredSessionId.set('s1')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', false)
|
||||
setSessionWorking('s1', false)
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const idle = makeState({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears unread when the user opens the session', () => {
|
||||
setSelectedStoredSessionId(() => 'other')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
setSelectedStoredSessionId('s1')
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -245,7 +245,6 @@ export const $messagingTruncated = atom<boolean>(false)
|
|||
// one. Empty for single-profile users (fall back to $sessionsTotal).
|
||||
export const $sessionProfileTotals = atom<Record<string, number>>({})
|
||||
export const $sessionsLoading = atom(true)
|
||||
export const $workingSessionIds = atom<string[]>([])
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||||
// Reactive signal for when the active session's stored id rotates (auto-
|
||||
|
|
@ -326,17 +325,20 @@ export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($mes
|
|||
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
|
||||
updateAtom($sessionProfileTotals, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
export const setActiveSessionStoredId = (next: Updater<string | null>) => updateAtom($activeSessionStoredId, next)
|
||||
|
||||
// Transient: a background session finished and the user hasn't opened it since.
|
||||
// Written by session-states.ts (handleTransition), cleared here on session open.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
|
||||
updateAtom($selectedStoredSessionId, next)
|
||||
// Opening a session clears its unread state — the user is now looking at it.
|
||||
const id = $selectedStoredSessionId.get()
|
||||
|
||||
if (id && $unreadFinishedSessionIds.get().includes(id)) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, id, false)
|
||||
$unreadFinishedSessionIds.set($unreadFinishedSessionIds.get().filter(x => x !== id))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -421,176 +423,3 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
|
|||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
// can take a few minutes legitimately. We allow 8 minutes of complete
|
||||
// silence on the stream before clearing the working flag; in practice this
|
||||
// catches gateway hangs and dropped streams without false-positive-clearing
|
||||
// real long turns.
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Notified (with the stored session id) whenever the watchdog force-clears a
|
||||
// stuck session. The session-state cache subscribes to also drop that session's
|
||||
// busy/awaiting flags — clearing `$workingSessionIds` alone only removes the
|
||||
// sidebar dot, leaving the composer stuck on "Thinking"/Stop for a hung or
|
||||
// looping turn that never streamed its terminal event.
|
||||
type SessionWatchdogListener = (storedSessionId: string) => void
|
||||
const sessionWatchdogListeners = new Set<SessionWatchdogListener>()
|
||||
|
||||
export function onSessionWatchdogClear(listener: SessionWatchdogListener): () => void {
|
||||
sessionWatchdogListeners.add(listener)
|
||||
|
||||
return () => void sessionWatchdogListeners.delete(listener)
|
||||
}
|
||||
|
||||
function armSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
|
||||
}
|
||||
|
||||
for (const listener of sessionWatchdogListeners) {
|
||||
listener(sessionId)
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
|
||||
sessionWatchdogTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
function clearSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// A session's "working" flag clears the instant its turn ends, but the
|
||||
// cross-profile aggregator (listSessions with min_messages=1) only sees the
|
||||
// just-persisted first turn a beat later. The active chat is shielded from that
|
||||
// race by sessionsToKeep(), but a brand-new session that finished *while you
|
||||
// were viewing a different chat* is, at the next refresh, neither working,
|
||||
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
|
||||
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
|
||||
// then click another session before the first reply lands.)
|
||||
//
|
||||
// To bridge that window we keep a session in the merge keep-set for a short
|
||||
// grace period after its turn settles, giving the aggregator time to catch up.
|
||||
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
|
||||
// session (mergeSessionPage only revives rows still present in the in-memory
|
||||
// list, which optimistic delete/archive already drops).
|
||||
const SESSION_SETTLE_GRACE_MS = 30 * 1000
|
||||
const settledSessionExpiry = new Map<string, number>()
|
||||
|
||||
function markSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
|
||||
}
|
||||
|
||||
function clearSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.delete(sessionId)
|
||||
}
|
||||
|
||||
/** Stored ids of sessions whose turn ended within the grace window. Prunes
|
||||
* expired entries as it reads, so it stays bounded without a timer. */
|
||||
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
|
||||
const live: string[] = []
|
||||
|
||||
for (const [id, expiry] of settledSessionExpiry) {
|
||||
if (expiry > now) {
|
||||
live.push(id)
|
||||
} else {
|
||||
settledSessionExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
// Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
|
||||
// the same array reference so subscribers don't churn).
|
||||
const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
|
||||
set(current => {
|
||||
const present = current.includes(id)
|
||||
|
||||
if (on) {
|
||||
return present ? current : [...current, id]
|
||||
}
|
||||
|
||||
return present ? current.filter(x => x !== id) : current
|
||||
})
|
||||
|
||||
// Stored session ids whose most recent turn finished while the user was
|
||||
// looking at a different session. The sidebar renders a steady green dot for
|
||||
// these so the user can tab back and find newly-completed work. Cleared on
|
||||
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => updateAtom($unreadFinishedSessionIds, next)
|
||||
|
||||
// Stored session ids with a blocking prompt (clarify) waiting on the user.
|
||||
// Separate from $workingSessionIds: a session can be "working" (turn running)
|
||||
// AND need input. The sidebar row reads this for a persistent indicator that,
|
||||
// unlike a toast, survives window blur / alt-tab.
|
||||
export const $attentionSessionIds = atom<string[]>([])
|
||||
export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
|
||||
|
||||
export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
|
||||
if (sessionId) {
|
||||
toggleMembership(setAttentionSessionIds, sessionId, needsInput)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasWorking = $workingSessionIds.get().includes(sessionId)
|
||||
|
||||
toggleMembership(setWorkingSessionIds, sessionId, working)
|
||||
|
||||
// Bookend the watchdog: arm on enter, disarm on leave. A later
|
||||
// noteSessionActivity() from a streaming event refreshes the timer.
|
||||
if (working) {
|
||||
clearSessionSettled(sessionId)
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
|
||||
// Only grant grace on a real working→idle transition (updateSessionState
|
||||
// re-asserts `false` on every state tick, which must not keep extending the
|
||||
// window). This keeps the just-finished session visible long enough for the
|
||||
// aggregator to return its now-persisted row.
|
||||
if (wasWorking) {
|
||||
markSessionSettled(sessionId)
|
||||
|
||||
// Mark unread when a background session finishes — only if the user
|
||||
// isn't currently viewing it. The active session's finish is seen live.
|
||||
if (sessionId !== $selectedStoredSessionId.get()) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, sessionId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue