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.
This commit is contained in:
Daisuke Suzuki 2026-07-31 15:55:25 +02:00
parent 1789e06ed8
commit 6502e441d4
6 changed files with 211 additions and 57 deletions

View File

@ -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

View File

@ -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 (

View File

@ -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 | SessionInfo[]>(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 (
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">

View File

@ -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

View File

@ -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')
}
}
})

View File

@ -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,
})