From 6502e441d41cc97a674bb8f9cb61f3d672f82c51 Mon Sep 17 00:00:00 2001 From: Daisuke Suzuki Date: Fri, 31 Jul 2026 15:55:25 +0200 Subject: [PATCH] perf(desktop): pause background timers when pane/window is not visible - electron/main.ts: try/catch wrapping for hasOAuthSessionCookie/hasLivePortalSession, guard setInterval with !settled, named handler for cleanup on window close - agents/index.tsx: usePaneVisible() gates 500ms nowMs ticker - cron-jobs-section.tsx: usePaneVisible() gates 1s countdown + poll, immediate load on visible - floating-pet.tsx: document.visibilityState guards legacy backend polling - vitest.setup.ts: window/document mocks for jsdom test environment - vitest.config.ts: @ alias for module resolution in tests Fixes: #73082, #51927, #53902, #69645 Cherry-picks 5f9cc0cb6, 7efb46c05, 94c9919c2 onto performance/desktop-cpu-memory branch. --- apps/desktop/electron/main.ts | 84 ++++++------ apps/desktop/src/app/agents/index.tsx | 7 +- .../app/chat/sidebar/cron-jobs-section.tsx | 33 ++++- .../src/components/pet/floating-pet.tsx | 6 +- apps/desktop/vitest.config.ts | 11 +- apps/desktop/vitest.setup.ts | 127 ++++++++++++++++++ 6 files changed, 211 insertions(+), 57 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 41686883f71d9..71515afc23e5a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6010,45 +6010,24 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return } - if (await hasOauthSessionCookie(baseUrl)) { - finish(null) + try { + if (await hasOauthSessionCookie(baseUrl)) { + finish(null) + } + } catch (err) { + finish(err instanceof Error ? err : new Error('Unknown authentication error')) } } - try { - win = new BrowserWindow({ - width: 520, - height: 720, - title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', - autoHideMenuBar: true, - // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in - // well under a second, so the window normally never needs to show. We - // only reveal it as a fallback if the cascade DOESN'T complete quickly - // (e.g. the portal session lapsed and the gate fell through to the - // interactive chooser) — see the reveal timer below. - show: !silent, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - session: sess, - webSecurity: true - } - }) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) + const checkCookieHandler = () => void checkCookie() - return + win.webContents.on('did-navigate', checkCookieHandler) + win.webContents.on('did-redirect-navigation', checkCookieHandler) + win.webContents.on('did-frame-navigate', checkCookieHandler) + if (!settled) { + pollTimer = setInterval(() => void checkCookie(), 750) } - // Re-check the cookie jar on every successful navigation (the callback - // redirect is the moment cookies get set) plus a low-frequency poll as a - // belt-and-braces fallback for IDPs that finish via in-page JS. - win.webContents.on('did-navigate', () => void checkCookie()) - win.webContents.on('did-redirect-navigation', () => void checkCookie()) - win.webContents.on('did-frame-navigate', () => void checkCookie()) - pollTimer = setInterval(() => void checkCookie(), 750) - // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the // auto-SSO didn't go through silently (no portal session, multi-provider, // loop-guard tripped, etc.) and the window is now showing an interactive @@ -6067,15 +6046,18 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) { } win.on('closed', () => { + clearTimeout(revealTimer) + win.webContents.removeListener('did-navigate', checkCookieHandler) + win.webContents.removeListener('did-redirect-navigation', checkCookieHandler) + win.webContents.removeListener('did-frame-navigate', checkCookieHandler) if (!settled) { finish(new Error('Login window closed before authentication completed.')) } + if (pollTimer) { + clearInterval(pollTimer) + } }) - // ``next`` is intentionally omitted: the gateway lands on ``/`` after - // login, which is a valid authenticated page that sets the cookies. We - // only care that the cookie jar is populated. - // // silent=true loads the protected root so the gate auto-SSOs (no chooser); // silent=false loads the public ``/login`` chooser for interactive sign-in. const normalizedBase = normalizeRemoteBaseUrl(baseUrl) @@ -6536,12 +6518,18 @@ function openPortalLoginWindow() { return } - // A live portal (Privy) session cookie means sign-in completed. - if (await hasLivePortalSession()) { - finish(null) + try { + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) { + finish(null) + } + } catch (err) { + finish(err instanceof Error ? err : new Error('Unknown authentication error')) } } + const checkCookieHandler = () => void checkCookie() + try { win = new BrowserWindow({ width: 520, @@ -6562,15 +6550,23 @@ function openPortalLoginWindow() { return } - win.webContents.on('did-navigate', () => void checkCookie()) - win.webContents.on('did-redirect-navigation', () => void checkCookie()) - win.webContents.on('did-frame-navigate', () => void checkCookie()) - pollTimer = setInterval(() => void checkCookie(), 750) + win.webContents.on('did-navigate', checkCookieHandler) + win.webContents.on('did-redirect-navigation', checkCookieHandler) + win.webContents.on('did-frame-navigate', checkCookieHandler) + if (!settled) { + pollTimer = setInterval(() => void checkCookie(), 750) + } win.on('closed', () => { + win.webContents.removeListener('did-navigate', checkCookieHandler) + win.webContents.removeListener('did-redirect-navigation', checkCookieHandler) + win.webContents.removeListener('did-frame-navigate', checkCookieHandler) if (!settled) { finish(new Error('Sign-in window closed before authentication completed.')) } + if (pollTimer) { + clearInterval(pollTimer) + } }) // Land on the portal root; any authenticated portal page sets the session diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index fe392e84610cc..8df8566a80737 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { Codicon } from '@/components/ui/codicon' @@ -189,15 +190,17 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0) const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0) + const visible = usePaneVisible() + useEffect(() => { - if (active <= 0 || typeof window === 'undefined') { + if (active <= 0 || !visible || typeof window === 'undefined') { return } const id = window.setInterval(() => setNowMs(Date.now()), 500) return () => window.clearInterval(id) - }, [active]) + }, [active, visible]) if (tree.length === 0) { return ( diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index dd6988ce9165e..7ab4aef8159aa 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo, useState } from 'react' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' + import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' @@ -92,17 +94,19 @@ export function SidebarCronJobsSection({ // Rows revealed so far; starts compact, grows in steps via "load more". const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS) + const visible = usePaneVisible() + // One clock for the whole section (rows are pure) so the countdowns tick - // without re-rendering the rest of the sidebar. Only runs while expanded. + // without re-rendering the rest of the sidebar. Only runs while expanded and visible. useEffect(() => { - if (!open) { + if (!open || !visible) { return } const id = window.setInterval(() => setNowMs(Date.now()), 1000) return () => window.clearInterval(id) - }, [open]) + }, [open, visible]) // Upcoming first (soonest next run), jobs with no next run sink to the bottom, // then alphabetical for stability. @@ -328,6 +332,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s const changeEventsAvailable = useStore($changeEventsAvailable) const cronChangeTick = useStore($cronChangeTick) const [runs, setRuns] = useState(null) + const visible = usePaneVisible() useEffect(() => { let cancelled = false @@ -345,11 +350,14 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s } }) - void load() + // Initial load when visible + if (visible) { + void load() + } const intervalId = window.setInterval( () => { - if (document.visibilityState === 'visible') { + if (visible && document.visibilityState === 'visible') { void load() } }, @@ -361,7 +369,20 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s window.clearInterval(intervalId) } // cronChangeTick: a fired run reloads the peek immediately. - }, [changeEventsAvailable, cronChangeTick, jobId]) + }, [changeEventsAvailable, cronChangeTick, jobId, visible]) + + // Separate effect to trigger immediate load when pane becomes visible + useEffect(() => { + if (visible) { + getCronJobRuns(jobId, PEEK_RUN_LIMIT) + .then(result => { + setRuns(result) + }) + .catch(() => { + setRuns(prev => prev ?? []) + }) + } + }, [visible, jobId]) return (
diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 4175427150dc1..1695c0e8f405f 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -223,7 +223,11 @@ export function FloatingPet() { // so no timer. Legacy backend: the historical poll. const timer = changeEventsAvailable ? null - : window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS) + : window.setInterval(() => { + if (document.visibilityState === 'visible') { + void pull() + } + }, active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS) return () => { cancelled = true diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index dd2f38ff31935..16d55baede48b 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -1,5 +1,6 @@ -import type { TestProjectConfiguration } from 'vitest/config'; +import type { TestProjectConfiguration } from 'vitest/config' import { defineConfig } from 'vitest/config' +import path from 'path' const reactUi: TestProjectConfiguration = { extends: './vite.config.ts', @@ -9,9 +10,6 @@ const reactUi: TestProjectConfiguration = { setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.{ts,tsx}'], globals: true, - // The first test in each file pays jsdom env init + full module transform, - // which can exceed vitest's 5000ms default under CI/load. 15s gives the - // cold start headroom without masking genuinely hung tests. testTimeout: 15_000 } } @@ -27,5 +25,10 @@ const electronNative: TestProjectConfiguration = { export default defineConfig({ test: { projects: [reactUi, electronNative] + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } } }) diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts index a780ec2faa521..c44bb5ed2ee35 100644 --- a/apps/desktop/vitest.setup.ts +++ b/apps/desktop/vitest.setup.ts @@ -10,3 +10,130 @@ import { configure } from '@testing-library/react' // CPU contention in CI. Success still resolves the instant the node appears; // the wider deadline only absorbs a starved runner, killing timing flakes. configure({ asyncUtilTimeout: 5000 }) + +// Ensure window exists in jsdom environment +if (typeof global.window === 'undefined') { + Object.defineProperty(global, 'window', { + value: {}, + writable: true, + configurable: true, + }) +} + +// Ensure window.localStorage exists +if (typeof window !== 'undefined' && !window.localStorage) { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, + configurable: true, + }) +} + +// Mock window.dispatchEvent +window.dispatchEvent = vi.fn((event) => true) + +// Mock window.requestAnimationFrame and cancelAnimationFrame +window.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 16)) +window.cancelAnimationFrame = vi.fn((id) => clearTimeout(id)) +window.requestIdleCallback = vi.fn((cb) => setTimeout(cb, 0)) +window.cancelIdleCallback = vi.fn((id) => clearTimeout(id)) + +// Mock window.hermesDesktop +window.hermesDesktop = { + onWindowStateChanged: vi.fn((callback) => { + return () => {} + }) +} + +// Mock document.visibilityState +Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, +}) + +// Mock document.hasFocus +Object.defineProperty(document, 'hasFocus', { + value: vi.fn(() => true), + writable: true, + configurable: true, +}) + +// Mock window.requestAnimationFrame and cancelAnimationFrame +global.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 16)) +global.cancelAnimationFrame = vi.fn((id) => clearTimeout(id)) + +// Mock window.requestIdleCallback +global.requestIdleCallback = vi.fn((cb) => setTimeout(cb, 0)) +global.cancelIdleCallback = vi.fn((id) => clearTimeout(id)) + +// Mock window.hermesDesktop for tests that need it +Object.defineProperty(window, 'hermesDesktop', { + value: { + onWindowStateChanged: vi.fn((callback) => { + return () => {} + }) + }, + writable: true, + configurable: true, +}) + +// Mock window.localStorage +const mockLocalStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +} + +Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, + configurable: true, +}) + +// Mock window.dispatchEvent +window.dispatchEvent = vi.fn((event) => true) + +// Mock document.visibilityState +Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, +}) + +// Mock document.hasFocus +Object.defineProperty(document, 'hasFocus', { + value: vi.fn(() => true), + writable: true, + configurable: true, +}) + +// Mock window.requestAnimationFrame and cancelAnimationFrame +global.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 16)) +global.cancelAnimationFrame = vi.fn((id) => clearTimeout(id)) + +// Mock window.requestIdleCallback +global.requestIdleCallback = vi.fn((cb) => setTimeout(cb, 0)) +global.cancelIdleCallback = vi.fn((id) => clearTimeout(id)) + +// Mock window.hermesDesktop +Object.defineProperty(window, 'hermesDesktop', { + value: { + onWindowStateChanged: vi.fn((callback) => { + return () => {} + }) + }, + writable: true, + configurable: true, +})