diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7c22ec152679..8ebd01db626e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -109,6 +109,7 @@ import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { createSessionStoreWatcher } from './session-store-watch' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -4749,6 +4750,65 @@ function registerPowerResumeListeners() { } } +// Sessions created OUTSIDE this app — a headless `hermes -z …` run, a cron job, +// a `hermes` CLI session in another terminal — write into the same profile +// stores the sidebar lists, but nothing told the renderer to look. Before this, +// the list only moved on boot and on the app's OWN `message.complete` events, so +// an external session needed a manual View > Reload to appear. +// +// Fan out to every window rather than just mainWindow: a session pop-out keeps +// its own store, and the renderer already gates on isSecondaryWindow() to decide +// whether it owns a sidebar. +function sendSessionsStoreChanged() { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + + const { webContents } = window + + if (!webContents || webContents.isDestroyed()) { + continue + } + + webContents.send('hermes:sessions-store-changed') + } +} + +let sessionStoreWatcher = null + +function registerSessionStoreWatcher() { + if (sessionStoreWatcher) { + return + } + + try { + sessionStoreWatcher = createSessionStoreWatcher({ + hermesHome: HERMES_HOME, + notify: sendSessionsStoreChanged, + onLog: rememberLog + }) + } catch (error) { + // Losing live refresh is a degraded sidebar, never a failed boot — the + // renderer's focused poll still covers it. + rememberLog(`[session-store-watch] disabled: ${error?.message ?? error}`) + } +} + +function stopSessionStoreWatcher() { + if (!sessionStoreWatcher) { + return + } + + try { + sessionStoreWatcher.close() + } catch { + void 0 + } + + sessionStoreWatcher = null +} + function getAppIconPath() { return APP_ICON_PATHS.find(fileExists) } @@ -9844,6 +9904,7 @@ app.whenReady().then(() => { ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() + registerSessionStoreWatcher() keepAwake.set(readPersistedKeepAwake()) createWindow() @@ -9906,6 +9967,8 @@ app.on('before-quit', () => { // pet can't keep the process alive or float over a quit app. closePetOverlay() + stopSessionStoreWatcher() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f05cce50191b..02d1cd8c9181 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -209,6 +209,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, + // A session store on disk changed in a way this window did not cause — most + // often a headless `hermes -z …` run or a CLI session in another terminal + // writing its transcript. The renderer re-pulls the sidebar list. + onSessionsStoreChanged: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:sessions-store-changed', listener) + + return () => ipcRenderer.removeListener('hermes:sessions-store-changed', listener) + }, // Soft gateway-mode apply finished tearing down the primary backend. Renderer // should wipe session lists + re-dial without a window reload. onConnectionApplied: callback => { diff --git a/apps/desktop/electron/session-store-watch.test.ts b/apps/desktop/electron/session-store-watch.test.ts new file mode 100644 index 000000000000..8d9cdc242384 --- /dev/null +++ b/apps/desktop/electron/session-store-watch.test.ts @@ -0,0 +1,288 @@ +/** + * Unit tests for the session-store watcher that keeps the sidebar current with + * sessions this app did not create (headless `hermes -z …`, cron, a CLI session + * in another terminal). + * + * Two behaviours are load-bearing and get the most attention: + * - We watch each profile's `sessions/` transcript directory, NOT the profile + * home. The home root is unusable: measured on macOS it churns every ~5s + * from kanban.db WAL renames alone, so a home watch would re-query every + * profile DB forever. + * - The throttle must not STARVE. A plain trailing debounce restarts its timer + * on every event, and an agent mid-run rewrites its transcript each turn — + * so the notify could be deferred for the entire run, which is exactly the + * case this feature exists for. + */ + +import assert from 'node:assert/strict' +import path from 'node:path' + +import { afterEach, test, vi } from 'vitest' + +import { + createSessionStoreWatcher, + SESSION_STORE_DEBOUNCE_MS, + SESSION_STORE_RESCAN_MS, + sessionStoreWatchDirs +} from './session-store-watch' + +const HOME = path.join('/tmp', 'hermes-home') + +/** In-memory fs stub: `dirs` is the set of paths that exist as directories. */ +function fakeFs(dirs: string[], watchLog?: Array<{ dir: string; handler: () => void }>) { + const set = new Set(dirs) + + return { + readdirSync: ((target: string) => { + const prefix = `${target}${path.sep}` + const children = new Set() + + for (const dir of set) { + if (dir.startsWith(prefix)) { + children.add(dir.slice(prefix.length).split(path.sep)[0]) + } + } + + if (!set.has(target) && children.size === 0) { + throw new Error(`ENOENT: ${target}`) + } + + return [...children] + }) as never, + statSync: ((target: string) => { + if (!set.has(target)) { + throw new Error(`ENOENT: ${target}`) + } + + return { isDirectory: () => true } + }) as never, + watch: ((dir: string, handler: () => void) => { + watchLog?.push({ dir, handler }) + + return { close: () => {}, on: () => {} } + }) as never + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +test('enumerates the default home plus every named profile transcript dir', () => { + const io = fakeFs([ + path.join(HOME, 'sessions'), + path.join(HOME, 'profiles', 'work'), + path.join(HOME, 'profiles', 'work', 'sessions'), + path.join(HOME, 'profiles', 'meshboard-worker'), + path.join(HOME, 'profiles', 'meshboard-worker', 'sessions') + ]) + + assert.deepEqual(sessionStoreWatchDirs(HOME, io), [ + path.join(HOME, 'sessions'), + path.join(HOME, 'profiles', 'meshboard-worker', 'sessions'), + path.join(HOME, 'profiles', 'work', 'sessions') + ]) +}) + +// A profile that has never run a session has no transcripts to watch. Skipping +// (rather than creating) keeps the watcher read-only against the user's store; +// the periodic rescan binds it the moment the directory appears. +test('skips profiles whose transcript dir does not exist yet', () => { + const io = fakeFs([path.join(HOME, 'sessions'), path.join(HOME, 'profiles', 'fresh')]) + + assert.deepEqual(sessionStoreWatchDirs(HOME, io), [path.join(HOME, 'sessions')]) +}) + +// Mirrors hermes_cli/profiles.py list_profiles(): `default` IS the root home +// (already added), and non-conforming names are not profiles. +test('ignores a nested "default" dir and names the CLI would not accept', () => { + const io = fakeFs([ + path.join(HOME, 'sessions'), + path.join(HOME, 'profiles', 'default'), + path.join(HOME, 'profiles', 'default', 'sessions'), + path.join(HOME, 'profiles', 'Not Valid'), + path.join(HOME, 'profiles', 'Not Valid', 'sessions'), + path.join(HOME, 'profiles', '-leading-dash'), + path.join(HOME, 'profiles', '-leading-dash', 'sessions') + ]) + + assert.deepEqual(sessionStoreWatchDirs(HOME, io), [path.join(HOME, 'sessions')]) +}) + +test('a home with no profiles dir still watches the default transcripts', () => { + const io = fakeFs([path.join(HOME, 'sessions')]) + + assert.deepEqual(sessionStoreWatchDirs(HOME, io), [path.join(HOME, 'sessions')]) +}) + +test('an empty home yields nothing to watch and does not throw', () => { + assert.deepEqual(sessionStoreWatchDirs(HOME, fakeFs([])), []) + assert.deepEqual(sessionStoreWatchDirs('', fakeFs([])), []) +}) + +test('binds a watch to every discovered transcript dir', () => { + const log: Array<{ dir: string; handler: () => void }> = [] + + const io = fakeFs( + [path.join(HOME, 'sessions'), path.join(HOME, 'profiles', 'work'), path.join(HOME, 'profiles', 'work', 'sessions')], + log + ) + + vi.useFakeTimers() + + const watcher = createSessionStoreWatcher({ hermesHome: HOME, notify: () => {}, fsImpl: io }) + + assert.deepEqual( + log.map(entry => entry.dir), + [path.join(HOME, 'sessions'), path.join(HOME, 'profiles', 'work', 'sessions')] + ) + assert.equal(watcher.watchedDirs().length, 2) + watcher.close() +}) + +test('collapses a burst of transcript writes into a single notify', () => { + vi.useFakeTimers() + + const log: Array<{ dir: string; handler: () => void }> = [] + const io = fakeFs([path.join(HOME, 'sessions')], log) + let notifies = 0 + const watcher = createSessionStoreWatcher({ hermesHome: HOME, notify: () => (notifies += 1), fsImpl: io }) + + log[0].handler() + log[0].handler() + log[0].handler() + + assert.equal(notifies, 0) + vi.advanceTimersByTime(SESSION_STORE_DEBOUNCE_MS) + assert.equal(notifies, 1) + + watcher.close() +}) + +// FAIL-BEFORE (design guard): with a trailing debounce that restarts on every +// event, a long agent run rewriting its transcript faster than the window would +// defer the notify indefinitely — the sidebar would stay stale for the entire +// run. The leading-scheduled throttle guarantees delivery at a bounded rate. +test('does not starve while writes keep arriving faster than the window', () => { + vi.useFakeTimers() + + const log: Array<{ dir: string; handler: () => void }> = [] + const io = fakeFs([path.join(HOME, 'sessions')], log) + let notifies = 0 + const watcher = createSessionStoreWatcher({ hermesHome: HOME, notify: () => (notifies += 1), fsImpl: io }) + + // A write every third of a window, for ten windows' worth of time. + for (let i = 0; i < 30; i++) { + log[0].handler() + vi.advanceTimersByTime(SESSION_STORE_DEBOUNCE_MS / 3) + } + + assert.equal(notifies, 10) + + watcher.close() +}) + +test('a notify that throws does not kill the watcher', () => { + vi.useFakeTimers() + + const log: Array<{ dir: string; handler: () => void }> = [] + const io = fakeFs([path.join(HOME, 'sessions')], log) + let notifies = 0 + + const watcher = createSessionStoreWatcher({ + hermesHome: HOME, + notify: () => { + notifies += 1 + throw new Error('window destroyed') + }, + fsImpl: io + }) + + log[0].handler() + vi.advanceTimersByTime(SESSION_STORE_DEBOUNCE_MS) + assert.equal(notifies, 1) + + log[0].handler() + vi.advanceTimersByTime(SESSION_STORE_DEBOUNCE_MS) + assert.equal(notifies, 2) + + watcher.close() +}) + +test('close stops the pending notify and every watch', () => { + vi.useFakeTimers() + + const closed: string[] = [] + const log: Array<{ dir: string; handler: () => void }> = [] + const io = fakeFs([path.join(HOME, 'sessions')], log) + const baseWatch = io.watch as unknown as (dir: string, handler: () => void) => unknown + + io.watch = ((dir: string, handler: () => void) => { + baseWatch(dir, handler) + + return { close: () => closed.push(dir), on: () => {} } + }) as never + + let notifies = 0 + const watcher = createSessionStoreWatcher({ hermesHome: HOME, notify: () => (notifies += 1), fsImpl: io }) + + log[0].handler() + watcher.close() + vi.advanceTimersByTime(SESSION_STORE_DEBOUNCE_MS * 5) + + assert.equal(notifies, 0) + assert.deepEqual(closed, [path.join(HOME, 'sessions')]) + assert.deepEqual(watcher.watchedDirs(), []) +}) + +// A profile created (or first used) after boot must start refreshing the +// sidebar without an app restart. +test('the rescan binds a transcript dir that appears after boot', () => { + vi.useFakeTimers() + + const dirs = [path.join(HOME, 'sessions')] + const log: Array<{ dir: string; handler: () => void }> = [] + const io = fakeFs(dirs, log) + // Re-point the stub at a growing set so the rescan sees the new profile. + const live = new Set(dirs) + + io.statSync = ((target: string) => { + if (!live.has(target)) { + throw new Error(`ENOENT: ${target}`) + } + + return { isDirectory: () => true } + }) as never + io.readdirSync = ((target: string) => { + const prefix = `${target}${path.sep}` + const children = new Set() + + for (const dir of live) { + if (dir.startsWith(prefix)) { + children.add(dir.slice(prefix.length).split(path.sep)[0]) + } + } + + if (!live.has(target) && children.size === 0) { + throw new Error(`ENOENT: ${target}`) + } + + return [...children] + }) as never + + const watcher = createSessionStoreWatcher({ hermesHome: HOME, notify: () => {}, fsImpl: io }) + + assert.equal(watcher.watchedDirs().length, 1) + + live.add(path.join(HOME, 'profiles', 'later')) + live.add(path.join(HOME, 'profiles', 'later', 'sessions')) + + vi.advanceTimersByTime(SESSION_STORE_RESCAN_MS) + + assert.deepEqual(watcher.watchedDirs(), [ + path.join(HOME, 'sessions'), + path.join(HOME, 'profiles', 'later', 'sessions') + ]) + + watcher.close() +}) diff --git a/apps/desktop/electron/session-store-watch.ts b/apps/desktop/electron/session-store-watch.ts new file mode 100644 index 000000000000..bb0378c491af --- /dev/null +++ b/apps/desktop/electron/session-store-watch.ts @@ -0,0 +1,244 @@ +import fs from 'node:fs' +import path from 'node:path' + +// Live session-list refresh for sessions this app did not create. +// +// The sidebar's rows come from each profile's state.db, but the renderer only +// re-pulled them on boot and on its OWN sessions' `message.complete` events. A +// headless `hermes -z …` run, a cron job, or a plain `hermes` CLI session in +// another terminal writes into the same store and stayed invisible until the +// user hit View > Reload. This watches the store and pings the renderer so its +// existing refresh path runs on its own. +// +// WHICH PATH TO WATCH (measured on macOS 2026-08-01, a ~/.hermes holding 5.5k +// transcripts and a 2.5 GB state.db): +// +// - The profile HOME directory is unusable as a trigger. Over a quiet 120s +// window it fired continuously: kanban.db-wal/-shm renames every 5s, each +// profile's state.db-wal every 10s, plus cron/ and the skills snapshot. A +// home-root watch would re-query every profile DB forever, whether or not +// any session existed. +// - `/sessions/` is quiet by comparison — over that same window it +// fired only for the transcript writes of the run under test. The agent +// writes the transcript through a temp file + atomic rename ~3s after a run +// starts and again after each turn, and that write lands together with the +// state.db row reaching message_count >= 1, which is exactly when the row +// becomes sidebar-eligible. So the transcript directory is both the +// quietest and the most accurate signal. +// +// SQLite WAL is why the obvious alternative fails: state.db itself is barely +// touched between checkpoints, so watching the db file misses live writes, and +// watching the -wal file breaks whenever a checkpoint recreates it. + +/** Collapse a burst of transcript writes into one renderer ping. */ +export const SESSION_STORE_DEBOUNCE_MS = 1500 + +/** Re-enumerate profile session dirs, picking up profiles created since boot. */ +export const SESSION_STORE_RESCAN_MS = 60_000 + +// Mirrors hermes_cli/profiles.py `_PROFILE_ID_RE` so we enumerate exactly the +// profile homes `list_profiles()` (and therefore the sessions endpoint) scans. +const PROFILE_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ + +export interface SessionStoreWatchFs { + readdirSync: typeof fs.readdirSync + statSync: typeof fs.statSync + watch: typeof fs.watch +} + +const defaultFs: SessionStoreWatchFs = { + readdirSync: fs.readdirSync, + statSync: fs.statSync, + watch: fs.watch +} + +function isDirectory(target: string, io: SessionStoreWatchFs): boolean { + try { + return io.statSync(target).isDirectory() + } catch { + return false + } +} + +/** + * Every `/sessions` directory that currently exists: the default + * home plus each named profile under `/profiles`. Missing directories are + * skipped rather than created — a profile that has never run a session has no + * transcripts to watch, and the periodic rescan picks it up once it does. + */ +export function sessionStoreWatchDirs(hermesHome: string, io: SessionStoreWatchFs = defaultFs): string[] { + if (!hermesHome) { + return [] + } + + const dirs: string[] = [] + + const addIfDir = (dir: string) => { + if (isDirectory(dir, io)) { + dirs.push(dir) + } + } + + addIfDir(path.join(hermesHome, 'sessions')) + + const profilesRoot = path.join(hermesHome, 'profiles') + + let entries: string[] = [] + + try { + entries = io.readdirSync(profilesRoot) as unknown as string[] + } catch { + return dirs + } + + for (const entry of [...entries].map(String).sort()) { + // `default` is the root home, already added above. + if (entry === 'default' || !PROFILE_ID_RE.test(entry)) { + continue + } + + if (isDirectory(path.join(profilesRoot, entry), io)) { + addIfDir(path.join(profilesRoot, entry, 'sessions')) + } + } + + return dirs +} + +export interface SessionStoreWatcher { + /** Directories under watch right now — exposed for tests/diagnostics. */ + watchedDirs: () => string[] + close: () => void +} + +export interface SessionStoreWatcherOptions { + hermesHome: string + /** Called (throttled) when a transcript write suggests the store changed. */ + notify: () => void + debounceMs?: number + rescanMs?: number + fsImpl?: SessionStoreWatchFs + onLog?: (message: string) => void +} + +/** + * Watch every profile's transcript directory and call `notify` when one moves. + * + * Throttle shape matters here. A plain trailing debounce would STARVE: an agent + * mid-run rewrites its transcript every turn, so a timer that restarts on each + * event may never fire while a long run is in progress — the exact case this + * feature exists for. Instead the first event of a burst schedules a single + * notify `debounceMs` later and every event until then is absorbed. That bounds + * the rate at one notify per window while guaranteeing the first change in any + * burst is delivered promptly. + */ +export function createSessionStoreWatcher({ + hermesHome, + notify, + debounceMs = SESSION_STORE_DEBOUNCE_MS, + rescanMs = SESSION_STORE_RESCAN_MS, + fsImpl = defaultFs, + onLog +}: SessionStoreWatcherOptions): SessionStoreWatcher { + const watchers = new Map() + let pending: ReturnType | null = null + let closed = false + + const schedule = () => { + // A notify is already queued for this burst — absorb the event. + if (closed || pending) { + return + } + + pending = setTimeout(() => { + pending = null + + try { + notify() + } catch { + // A dead window / destroyed webContents must not kill the watcher. + } + }, debounceMs) + } + + const bind = (dir: string) => { + if (watchers.has(dir)) { + return + } + + try { + const watcher = fsImpl.watch(dir, () => schedule()) + + // A watched directory can vanish (profile deleted). Drop it and let the + // rescan re-bind if it comes back, rather than throwing into main. + watcher.on('error', () => { + watchers.delete(dir) + + try { + watcher.close() + } catch { + // already gone + } + }) + + watchers.set(dir, watcher) + onLog?.(`[session-store-watch] watching ${dir}`) + } catch (err) { + onLog?.(`[session-store-watch] failed to watch ${dir}: ${(err as Error)?.message ?? err}`) + } + } + + const rescan = () => { + if (closed) { + return + } + + const wanted = new Set(sessionStoreWatchDirs(hermesHome, fsImpl)) + + for (const [dir, watcher] of watchers) { + if (!wanted.has(dir)) { + watchers.delete(dir) + + try { + watcher.close() + } catch { + // already gone + } + } + } + + for (const dir of wanted) { + bind(dir) + } + } + + rescan() + + const rescanTimer = setInterval(rescan, rescanMs) + + // Never hold the process open just to poll for new profiles. + rescanTimer.unref?.() + + return { + watchedDirs: () => [...watchers.keys()], + close: () => { + closed = true + clearInterval(rescanTimer) + + if (pending) { + clearTimeout(pending) + pending = null + } + + for (const watcher of watchers.values()) { + try { + watcher.close() + } catch { + // already gone + } + } + + watchers.clear() + } + } +} diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index d0af49665906..d455025d05b7 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -10,6 +10,7 @@ import { isSecondaryWindow } from '@/store/windows' import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus' import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes' +import { useExternalSessionSync } from '../../session/hooks/use-external-session-sync' interface DesktopIntegrationsParams { chatOpen: boolean @@ -168,4 +169,9 @@ export function useDesktopIntegrations({ return onSessionsChanged(() => void refreshSessions()) }, [refreshSessions]) + + // A process OUTSIDE this app (headless `hermes -z …`, cron, a CLI session in + // another terminal) wrote into a profile store -> re-pull the sidebar. The + // BroadcastChannel above only reaches our own windows. + useExternalSessionSync({ refreshSessions }) } diff --git a/apps/desktop/src/app/session/hooks/use-external-session-sync.test.tsx b/apps/desktop/src/app/session/hooks/use-external-session-sync.test.tsx new file mode 100644 index 000000000000..2537a18a4263 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-external-session-sync.test.tsx @@ -0,0 +1,243 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { EXTERNAL_SESSION_POLL_MS, useExternalSessionSync } from './use-external-session-sync' + +// Sessions created outside this app (headless `hermes -z …`, cron, a CLI +// session in another terminal) used to need a manual View > Reload to appear. +// This hook is the renderer half of the fix: an Electron-main watch signal plus +// a focused-only safety-net poll, funnelled through one non-overlapping refresh. + +const secondary = vi.hoisted(() => ({ value: false })) + +vi.mock('@/store/windows', () => ({ + isSecondaryWindow: () => secondary.value +})) + +let storeChangedHandlers: Array<() => void> = [] +let unsubscribes = 0 +let focused = true + +function stubDesktopBridge() { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + onSessionsStoreChanged: (callback: () => void) => { + storeChangedHandlers.push(callback) + + return () => { + unsubscribes += 1 + storeChangedHandlers = storeChangedHandlers.filter(h => h !== callback) + } + } + } +} + +beforeEach(() => { + vi.useFakeTimers() + secondary.value = false + storeChangedHandlers = [] + unsubscribes = 0 + focused = true + vi.spyOn(document, 'hasFocus').mockImplementation(() => focused) + stubDesktopBridge() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +const setFocus = (next: boolean) => { + focused = next + window.dispatchEvent(new Event(next ? 'focus' : 'blur')) +} + +it('refreshes the session list when Electron main reports a store change', async () => { + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + expect(storeChangedHandlers).toHaveLength(1) + + await act(async () => { + storeChangedHandlers[0]() + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) +}) + +// REGRESSION GUARD. An earlier interval poll here (fork commit 209a9c38d) fired +// every 30s against an API call that could block for 45s while the backend was +// wedged. Overlapping refreshes meant `refreshSessions` only ever cleared its +// loading flag for the LATEST request id, so the sidebar skeletons never +// cleared — a recoverable stall became a permanent spinner. Signals arriving +// during an in-flight refresh must coalesce into ONE trailing pass, never stack. +it('never overlaps refreshes; concurrent signals coalesce into one trailing pass', async () => { + let release: (() => void) | null = null + + const refreshSessions = vi.fn( + () => + new Promise(resolve => { + release = () => resolve() + }) + ) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + await act(async () => { + storeChangedHandlers[0]() + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + + // Five more signals while the first refresh is still pending. + await act(async () => { + for (let i = 0; i < 5; i++) { + storeChangedHandlers[0]() + } + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + + const first = release! + + await act(async () => { + first() + await Promise.resolve() + }) + + // Exactly one trailing pass covers all five, not five more calls. + expect(refreshSessions).toHaveBeenCalledTimes(2) + + await act(async () => { + release!() + await Promise.resolve() + }) + + expect(refreshSessions).toHaveBeenCalledTimes(2) +}) + +it('a rejected refresh does not wedge the hook for later signals', async () => { + const refreshSessions = vi.fn(async () => { + throw new Error('backend unreachable') + }) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + await act(async () => { + storeChangedHandlers[0]() + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + + await act(async () => { + storeChangedHandlers[0]() + }) + + expect(refreshSessions).toHaveBeenCalledTimes(2) +}) + +it('polls on an interval while focused', async () => { + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + expect(refreshSessions).not.toHaveBeenCalled() + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(2) +}) + +it('stops polling entirely while the window is unfocused', async () => { + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + await act(async () => { + setFocus(false) + }) + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS * 10) + }) + + expect(refreshSessions).not.toHaveBeenCalled() +}) + +// The watch signal cannot be trusted across a long background stretch (events +// can be dropped, and a store on a network mount may not emit at all), so +// coming back to the app catches up immediately rather than waiting a tick. +it('refreshes once on regaining focus, and not on losing it', async () => { + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + await act(async () => { + setFocus(false) + }) + + expect(refreshSessions).not.toHaveBeenCalled() + + await act(async () => { + setFocus(true) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) +}) + +it('a secondary session window neither subscribes nor polls', async () => { + secondary.value = true + + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + expect(storeChangedHandlers).toHaveLength(0) + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS * 5) + }) + + expect(refreshSessions).not.toHaveBeenCalled() +}) + +it('unsubscribes and clears its timer on unmount', async () => { + const refreshSessions = vi.fn(async () => undefined) + + const { unmount } = renderHook(() => useExternalSessionSync({ refreshSessions })) + + unmount() + + expect(unsubscribes).toBe(1) + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS * 5) + }) + + expect(refreshSessions).not.toHaveBeenCalled() +}) + +// An older preload (app updated ahead of a cached bundle) exposes no +// onSessionsStoreChanged. The poll must still work rather than throwing. +it('degrades to the poll when the preload has no store-change bridge', async () => { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {} + + const refreshSessions = vi.fn(async () => undefined) + + renderHook(() => useExternalSessionSync({ refreshSessions })) + + await act(async () => { + vi.advanceTimersByTime(EXTERNAL_SESSION_POLL_MS) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) +}) diff --git a/apps/desktop/src/app/session/hooks/use-external-session-sync.ts b/apps/desktop/src/app/session/hooks/use-external-session-sync.ts new file mode 100644 index 000000000000..f728a1ae7278 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-external-session-sync.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef } from 'react' + +import { isSecondaryWindow } from '@/store/windows' + +/** + * Safety-net poll interval, used ONLY while the window is focused and visible. + * + * fs.watch is the primary signal (Electron main pings us on transcript writes); + * this covers the cases it structurally cannot see — a store on a network mount, + * a platform where the watch failed to bind, or events dropped while the app was + * in the background. + */ +export const EXTERNAL_SESSION_POLL_MS = 30_000 + +interface UseExternalSessionSyncArgs { + refreshSessions: () => Promise | unknown +} + +/** + * Keep the sidebar current with sessions this app did not create. + * + * A headless `hermes -z …` run, a cron job, or a `hermes` CLI session in another + * terminal writes into the same profile stores the sidebar lists. The renderer + * used to learn about session changes only from its OWN `message.complete` + * events and the boot fetch, so those rows needed a manual View > Reload. + * + * Two inputs, one coalesced refresh: + * 1. `onSessionsStoreChanged` — Electron main watches each profile's + * transcript directory and pings us (throttled there). + * 2. A focused-only poll + a refresh when the window regains focus. + * + * NON-OVERLAP IS LOAD-BEARING, not tidiness. An earlier interval poll here + * (fork commit 209a9c38d) fired every 30s against an API call that could block + * for 45s while the backend was wedged. The refreshes overlapped continuously, + * and because `refreshSessions` only clears its loading flag for the LATEST + * request id, the sidebar skeletons never cleared — a recoverable backend stall + * became a permanent spinner. So: at most one refresh in flight, with a single + * trailing re-run if signals arrived while it was running. + */ +export function useExternalSessionSync({ refreshSessions }: UseExternalSessionSyncArgs): void { + // Held in a ref so the subscription and the interval bind ONCE. `refreshSessions` + // is rebuilt whenever the sidebar's profile scope changes; depending on it + // directly would tear down and re-add the IPC listener and restart the poll + // clock on every such change. Assigned in an effect, not during render — + // a concurrent render that React discards must not publish its closure. + const refreshRef = useRef(refreshSessions) + + useEffect(() => { + refreshRef.current = refreshSessions + }, [refreshSessions]) + + const inFlightRef = useRef(false) + const queuedRef = useRef(false) + + const run = useCallback(async () => { + if (inFlightRef.current) { + // Coalesce: whatever arrived is covered by one more pass after this one. + queuedRef.current = true + + return + } + + inFlightRef.current = true + + try { + do { + queuedRef.current = false + await refreshRef.current() + } while (queuedRef.current) + } catch { + // Non-fatal: the sidebar keeps its last-known rows and the next signal + // (or poll tick) retries. + } finally { + queuedRef.current = false + inFlightRef.current = false + } + }, []) + + // Signal 1 — Electron main saw a transcript land in a watched profile store. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + const unsubscribe = window.hermesDesktop?.onSessionsStoreChanged?.(() => void run()) + + return () => unsubscribe?.() + }, [run]) + + // Signal 2 — focused-only safety net. No timer runs while the window is + // blurred or hidden, so a backgrounded app costs nothing. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + let timer: null | ReturnType = null + + const stop = () => { + if (timer) { + clearInterval(timer) + timer = null + } + } + + const start = () => { + if (!timer) { + timer = setInterval(() => void run(), EXTERNAL_SESSION_POLL_MS) + } + } + + const active = () => document.hasFocus() && document.visibilityState !== 'hidden' + + const sync = () => (active() ? start() : stop()) + + // Coming back to the app is the moment stale rows are most visible, and it + // is also when watch events dropped in the background need catching up. + // Going away only stops the timer — never refresh on the way out. + const onActivate = () => { + sync() + + if (active()) { + void run() + } + } + + sync() + + window.addEventListener('focus', onActivate) + window.addEventListener('blur', sync) + document.addEventListener('visibilitychange', onActivate) + + return () => { + stop() + window.removeEventListener('focus', onActivate) + window.removeEventListener('blur', sync) + document.removeEventListener('visibilitychange', onActivate) + } + }, [run]) +} diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 0147d45e4a8b..82b9c3d370c8 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -160,6 +160,95 @@ describe('refreshSessions identity + loading hygiene', () => { off() expect(loadingStates).toEqual([false, true, false]) }) + + // REGRESSION GUARD for the 2026-06-21 permanent-skeleton wedge. An app/backend + // version skew made every `hermes:api` call hang to its 45s timeout while a 30s + // interval poll kept firing. Each refresh was superseded before it settled, so + // the old `requestId === current` gate never matched in any `finally` and + // $sessionsLoading stayed true forever — a recoverable stall turned into a + // sidebar stuck on skeletons. The flag must clear once nothing is in flight, + // whichever request finishes last. + it('clears the loading flag after overlapping refreshes settle out of order', async () => { + const releases: Array<() => void> = [] + + listSidebarSessions.mockImplementation( + () => + new Promise(resolve => { + releases.push(() => resolve(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))) + }) + ) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + // Three overlapping refreshes over an EMPTY list — all take showLoading. + const pending: Array> = [] + + await act(async () => { + pending.push(result.current.refreshSessions()) + pending.push(result.current.refreshSessions()) + pending.push(result.current.refreshSessions()) + await Promise.resolve() + }) + + expect($sessionsLoading.get()).toBe(true) + + // Settle oldest-first, so every response is a SUPERSEDED one (current id is + // already 3). Under the old gate none of these could clear the flag. + await act(async () => { + releases[0]() + await Promise.resolve() + }) + + expect($sessionsLoading.get()).toBe(false) + + await act(async () => { + releases[1]() + releases[2]() + await Promise.all(pending) + }) + + expect($sessionsLoading.get()).toBe(false) + }) + + // The other half of the wedge: if the backend is unreachable every refresh + // REJECTS, so the success path never runs. Once nothing is left in flight the + // sidebar must stop showing skeletons rather than spinning forever. + it('clears the loading flag once every failing refresh has settled', async () => { + const rejects: Array<() => void> = [] + + listSidebarSessions.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejects.push(() => reject(new Error('backend unreachable'))) + }) + ) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + const pending: Array> = [] + + await act(async () => { + pending.push(result.current.refreshSessions().catch(() => undefined)) + pending.push(result.current.refreshSessions().catch(() => undefined)) + await Promise.resolve() + }) + + expect($sessionsLoading.get()).toBe(true) + + await act(async () => { + rejects[0]() + await Promise.resolve() + }) + + // One still in flight — keep the skeletons up. + expect($sessionsLoading.get()).toBe(true) + + await act(async () => { + rejects[1]() + await Promise.all(pending) + }) + + expect($sessionsLoading.get()).toBe(false) + }) }) describe('refreshSessions batches slices into one request', () => { 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 38850cad3a77..f63296ea5f07 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,6 +15,7 @@ import { $messagingSessions, $selectedStoredSessionId, $sessions, + $sessionsLoading, CRON_SECTION_LIMIT, mergeSessionPage, MESSAGING_SECTION_LIMIT, @@ -74,6 +75,10 @@ interface UseSessionListActionsArgs { * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { const refreshSessionsRequestRef = useRef(0) + // How many refreshes are in flight right now. Drives the loading-flag reset + // (see the `finally` in refreshSessions) so overlapping refreshes cannot + // strand the sidebar in its skeleton state. + const refreshSessionsInFlightRef = useRef(0) // Messaging-platform sessions as their own slice, fetched separately from // local recents so each platform renders a self-managed section and never @@ -150,6 +155,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setSessionsLoading(true) } + refreshSessionsInFlightRef.current += 1 + try { const limit = $sessionsLimit.get() @@ -177,6 +184,14 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg messagingExclude: MESSAGING_EXCLUDED_SOURCES }) + // The backend answered, so the skeletons are done regardless of whether + // THIS request is still the newest — a superseded one is already being + // replaced by a live request, and an empty result should render the empty + // state, not a spinner. Clearing here (not only in `finally`) means even + // permanently overlapping refreshes cannot strand the sidebar loading. + // No-ops when the flag is already false, so this adds no store churn. + setSessionsLoading(false) + if (refreshSessionsRequestRef.current === requestId) { const recents = result.recents @@ -213,7 +228,17 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) } } finally { - if (showLoading && refreshSessionsRequestRef.current === requestId) { + refreshSessionsInFlightRef.current -= 1 + + // Clear when the LAST in-flight refresh settles, not only when the NEWEST + // one does. Gating solely on `requestId === current` wedged the sidebar on + // 2026-06-21: an app/backend skew made every `hermes:api` call hang to its + // 45s timeout while a 30s interval poll kept firing, so each refresh was + // superseded before it settled, no `finally` ever matched the newest id, + // and $sessionsLoading stayed true forever — permanent skeletons from a + // recoverable stall. A counter cannot get stuck: when nothing is in + // flight there is nothing left to turn the flag off. + if (refreshSessionsInFlightRef.current === 0 && $sessionsLoading.get()) { setSessionsLoading(false) } } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 957639a3e990..93c71a42f240 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -198,6 +198,10 @@ declare global { onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void + // A profile's session store changed on disk without this app causing it + // (headless `hermes -z …`, cron, a CLI session elsewhere). Re-pull the + // sidebar list. Optional: an older preload won't expose it. + onSessionsStoreChanged?: (callback: () => void) => () => void // Soft gateway-mode apply: primary backend was torn down without a window // reload. Wipe session lists (skeletons) and re-dial. onConnectionApplied?: (callback: () => void) => () => void