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:
parent
1789e06ed8
commit
6502e441d4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue