diff --git a/config/scripts/idle-cpu-synthetic-spinners.mjs b/config/scripts/idle-cpu-synthetic-spinners.mjs new file mode 100644 index 00000000000..dfd98526486 --- /dev/null +++ b/config/scripts/idle-cpu-synthetic-spinners.mjs @@ -0,0 +1,44 @@ +export async function installSyntheticVisibleSpinners(page, count, animation, steps) { + if (count <= 0) { + return + } + const animationTiming = + animation === 'steps' ? `1s steps(${steps}, end) infinite` : '1s linear infinite' + await page.addStyleTag({ + content: ` + @keyframes orca-idle-bench-spin { + to { transform: rotate(360deg); } + } + .orca-idle-bench-spinner-host { + position: fixed; + top: 16px; + right: 16px; + z-index: 2147483647; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + } + .orca-idle-bench-spinner { + width: 10px; + height: 10px; + border: 2px solid rgb(234 179 8); + border-top-color: transparent; + border-radius: 9999px; + animation: orca-idle-bench-spin ${animationTiming}; + } + ` + }) + await page.evaluate((spinnerCount) => { + document.querySelector('[data-orca-idle-bench-spinners]')?.remove() + const host = document.createElement('div') + host.className = 'orca-idle-bench-spinner-host' + host.setAttribute('data-orca-idle-bench-spinners', String(spinnerCount)) + for (let index = 0; index < spinnerCount; index += 1) { + const spinner = document.createElement('div') + spinner.className = 'orca-idle-bench-spinner' + host.appendChild(spinner) + } + document.body.appendChild(host) + }, count) +} diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs new file mode 100644 index 00000000000..6390d7b5128 --- /dev/null +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -0,0 +1,591 @@ +#!/usr/bin/env node +import { _electron as electron } from '@stablyai/playwright-test' +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs' + +const DEFAULT_WARMUP_MS = 15_000 +const DEFAULT_SAMPLE_MS = 30_000 +const DEFAULT_INTERVAL_MS = 1_000 +const DEFAULT_WORKTREE_COUNT = 1 +const ONBOARDING_FINAL_STEP = 3 +const ONBOARDING_FLOW_VERSION = 2 + +function parseArgs(argv) { + const options = { + warmupMs: DEFAULT_WARMUP_MS, + sampleMs: DEFAULT_SAMPLE_MS, + intervalMs: DEFAULT_INTERVAL_MS, + worktrees: DEFAULT_WORKTREE_COUNT, + skipBuild: false, + headful: false, + output: null, + disableRendererAnimations: false, + syntheticVisibleSpinners: 0, + syntheticSpinnerAnimation: 'smooth', + syntheticSpinnerSteps: 12 + } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const readValue = () => { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${arg}`) + } + index += 1 + return value + } + if (arg === '--') { + continue + } else if (arg === '--warmup-ms') { + options.warmupMs = Number(readValue()) + } else if (arg === '--sample-ms') { + options.sampleMs = Number(readValue()) + } else if (arg === '--interval-ms') { + options.intervalMs = Number(readValue()) + } else if (arg === '--worktrees') { + options.worktrees = Number(readValue()) + } else if (arg === '--output') { + options.output = readValue() + } else if (arg === '--skip-build') { + options.skipBuild = true + } else if (arg === '--headful') { + options.headful = true + } else if (arg === '--disable-renderer-animations') { + options.disableRendererAnimations = true + } else if (arg === '--synthetic-visible-spinners') { + options.syntheticVisibleSpinners = Number(readValue()) + } else if (arg === '--synthetic-spinner-animation') { + options.syntheticSpinnerAnimation = readValue() + } else if (arg === '--synthetic-spinner-steps') { + options.syntheticSpinnerSteps = Number(readValue()) + } else if (arg === '--help') { + printUsage() + process.exit(0) + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + for (const key of [ + 'warmupMs', + 'sampleMs', + 'intervalMs', + 'worktrees', + 'syntheticVisibleSpinners', + 'syntheticSpinnerSteps' + ]) { + if (!Number.isFinite(options[key]) || options[key] < 0) { + throw new Error(`Invalid --${key}: ${options[key]}`) + } + } + options.worktrees = Math.max(1, Math.floor(options.worktrees)) + options.intervalMs = Math.max(250, Math.floor(options.intervalMs)) + options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners)) + options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps)) + if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) { + throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`) + } + return options +} +function printUsage() { + console.log( + `Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners Measurement-only: add visible working spinners\n --synthetic-spinner-animation Spinner animation style (default smooth)\n --synthetic-spinner-steps Step count for --synthetic-spinner-animation steps (default 12)\n` + ) +} +function run(command, args, options = {}) { + execFileSync(command, args, { stdio: options.stdio ?? 'pipe', encoding: 'utf8', ...options }) +} + +function buildAppIfNeeded(root, skipBuild) { + const mainPath = path.join(root, 'out', 'main', 'index.js') + if (skipBuild && existsSync(mainPath)) { + return mainPath + } + if (skipBuild) { + throw new Error(`--skip-build requested, but ${mainPath} does not exist`) + } + console.log('[idle-cpu] building Electron app with electron-vite --mode e2e') + run('npx', ['electron-vite', 'build', '--mode', 'e2e'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, VITE_EXPOSE_STORE: 'true' } + }) + return mainPath +} + +function makeCompletedOnboardingProfile() { + return { + settings: { + telemetry: { + optedIn: true, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: ONBOARDING_FINAL_STEP + }, + ui: { + contextualToursSeenIds: [ + 'workspace-board', + 'browser', + 'tasks', + 'automations', + 'workspace-creation' + ], + contextualToursAutoEligible: false, + projectOrderManualDefaultNoticeDismissed: true + } + } +} + +function createIdleRepo(worktreeCount) { + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-repo-')) + const cleanupDirs = [repoDir] + run('git', ['init'], { cwd: repoDir }) + run('git', ['config', 'user.email', 'idle-cpu@test.local'], { cwd: repoDir }) + run('git', ['config', 'user.name', 'Idle CPU Benchmark'], { cwd: repoDir }) + writeFileSync(path.join(repoDir, 'README.md'), '# Orca idle CPU benchmark\n') + writeFileSync( + path.join(repoDir, 'package.json'), + `${JSON.stringify({ private: true }, null, 2)}\n` + ) + mkdirSync(path.join(repoDir, 'src'), { recursive: true }) + writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const idleBenchmark = true\n') + run('git', ['add', '-A'], { cwd: repoDir }) + run('git', ['commit', '-m', 'Initial idle CPU fixture'], { cwd: repoDir }) + for (let i = 2; i <= worktreeCount; i += 1) { + const worktreeDir = path.join( + path.dirname(repoDir), + `orca-idle-cpu-worktree-${i}-${Date.now()}` + ) + cleanupDirs.push(worktreeDir) + run('git', ['worktree', 'add', worktreeDir, '-b', `idle-cpu-${i}`], { cwd: repoDir }) + } + return { repoDir, cleanupDirs } +} + +function launchArgs(mainPath, headful) { + if (headful || process.platform !== 'linux') { + return [mainPath] + } + return [ + '--disable-gpu', + '--disable-gpu-compositing', + '--disable-gpu-sandbox', + '--disable-dev-shm-usage', + '--in-process-gpu', + mainPath + ] +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function parseCpuTimeSeconds(value) { + const trimmed = String(value || '').trim() + if (!trimmed) { + return null + } + const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed] + const days = dayOrTime === null ? 0 : Number(dayOrTime) + const parts = maybeTime.split(':').map(Number) + if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { + return null + } + if (parts.length === 3) { + return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2] + } + if (parts.length === 2) { + return days * 86400 + parts[0] * 60 + parts[1] + } + if (parts.length === 1) { + return days * 86400 + parts[0] + } + return null +} + +function parseUnixProcesses(stdout) { + const rows = [] + for (const raw of stdout.split('\n')) { + const line = raw.trim() + if (!line) { + continue + } + const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/) + if (!match) { + continue + } + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + percentCpu: Number(match[3]), + rssBytes: Number(match[4]) * 1024, + cpuTimeSeconds: parseCpuTimeSeconds(match[5]), + command: match[6] + }) + } + return rows +} + +function readUnixProcesses() { + const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], { + encoding: 'utf8', + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + maxBuffer: 20 * 1024 * 1024 + }) + return parseUnixProcesses(stdout) +} + +function readWindowsProcesses() { + const script = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + if (result.status !== 0) { + throw new Error(result.stderr || 'PowerShell process enumeration failed') + } + const parsed = JSON.parse(result.stdout || '[]') + const entries = Array.isArray(parsed) ? parsed : [parsed] + return entries.map((entry) => ({ + pid: Number(entry.ProcessId), + ppid: Number(entry.ParentProcessId), + percentCpu: 0, + cpuTimeSeconds: null, + rssBytes: Number(entry.WorkingSetSize) || 0, + command: String(entry.CommandLine || '') + })) +} + +function readProcessRows() { + return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses() +} + +function descendantsOf(rows, rootPid) { + const children = new Map() + for (const row of rows) { + const list = children.get(row.ppid) ?? [] + list.push(row) + children.set(row.ppid, list) + } + const result = [] + const stack = [rootPid] + const seen = new Set() + while (stack.length > 0) { + const pid = stack.pop() + if (seen.has(pid)) { + continue + } + seen.add(pid) + const row = rows.find((candidate) => candidate.pid === pid) + if (row) { + result.push(row) + } + for (const child of children.get(pid) ?? []) { + stack.push(child.pid) + } + } + return result +} + +function classify(row, rootPid) { + const command = row.command.toLowerCase() + if (row.pid === rootPid) { + return 'main' + } + if (command.includes('daemon-entry')) { + return 'daemon' + } + if (command.includes('--type=gpu-process')) { + return 'gpu' + } + if (command.includes('--type=renderer')) { + return 'renderer' + } + if (command.includes('--type=utility')) { + return 'utility' + } + if (command.includes('--type=')) { + return 'electron-other' + } + if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) { + return 'agent-or-node' + } + return 'other-descendant' +} + +async function collectRendererIdleState(page) { + return page.evaluate(() => { + const describeElement = (element) => { + if (!(element instanceof Element)) { + return null + } + const classes = typeof element.className === 'string' ? element.className : '' + const testId = element.getAttribute('data-testid') + const label = element.getAttribute('aria-label') + return { + tag: element.tagName.toLowerCase(), + id: element.id || null, + testId, + label, + classes: classes.split(/\s+/).filter(Boolean).slice(0, 12), + text: (element.textContent || '').trim().slice(0, 80) + } + } + const animations = document.getAnimations({ subtree: true }).map((animation) => { + const effect = animation.effect + const target = effect instanceof KeyframeEffect ? effect.target : null + return { + playState: animation.playState, + currentTime: typeof animation.currentTime === 'number' ? animation.currentTime : null, + playbackRate: animation.playbackRate, + duration: + effect instanceof KeyframeEffect && typeof effect.getTiming().duration === 'number' + ? effect.getTiming().duration + : null, + iterations: effect instanceof KeyframeEffect ? effect.getTiming().iterations : null, + target: describeElement(target) + } + }) + return { + visibilityState: document.visibilityState, + runningAnimationCount: animations.filter((animation) => animation.playState === 'running') + .length, + animations: animations.slice(0, 80) + } + }) +} + +function summarizeSamples(samples) { + const byKind = new Map() + for (const sample of samples) { + for (const proc of sample.processes) { + const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 } + bucket.cpuValues.push(proc.cpu) + bucket.rssValues.push(proc.rssBytes) + byKind.set(proc.kind, bucket) + } + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + } + for (const [kind, count] of counts) { + byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count) + } + } + const summary = {} + for (const [kind, values] of byKind) { + const cpuSorted = [...values.cpuValues].sort((a, b) => a - b) + const rssSumBySample = samples.map((sample) => + sample.processes + .filter((proc) => proc.kind === kind) + .reduce((sum, proc) => sum + proc.rssBytes, 0) + ) + summary[kind] = { + meanCpuPercent: mean(values.cpuValues), + p95CpuPercent: percentile(cpuSorted, 0.95), + maxCpuPercent: Math.max(0, ...values.cpuValues), + meanRssBytes: mean(rssSumBySample), + maxProcessCount: values.maxProcessCount + } + } + summary.total = { + meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)), + p95CpuPercent: percentile( + samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b), + 0.95 + ), + meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes)) + } + return summary +} + +function summarizeProcessInventory(samples) { + const inventory = {} + for (const sample of samples) { + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + const entry = inventory[proc.kind] ?? { + maxProcessCount: 0, + maxCpuPercent: 0, + commandSamples: [] + } + entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu) + if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) { + entry.commandSamples.push(proc.command) + } + inventory[proc.kind] = entry + } + for (const [kind, count] of counts) { + inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count) + } + } + return inventory +} +function mean(values) { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length +} + +function percentile(sorted, fraction) { + if (sorted.length === 0) { + return 0 + } + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1) + return sorted[index] +} + +function terminateProcesses(processes) { + for (const proc of processes) { + try { + process.kill(proc.pid) + } catch {} + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + const mainPath = buildAppIfNeeded(root, options.skipBuild) + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-userdata-')) + const { repoDir, cleanupDirs } = createIdleRepo(options.worktrees) + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(makeCompletedOnboardingProfile(), null, 2)}\n` + ) + const { ELECTRON_RUN_AS_NODE, ...cleanEnv } = process.env + void ELECTRON_RUN_AS_NODE + const app = await electron.launch({ + args: launchArgs(mainPath, options.headful), + env: { + ...cleanEnv, + NODE_ENV: 'development', + ORCA_E2E_USER_DATA_DIR: userDataDir, + ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + } + }) + const rootPid = app.process().pid + try { + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + const measurementCss = [] + if (options.disableRendererAnimations) { + measurementCss.push( + '*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}' + ) + } + if (measurementCss.length > 0) { + await page.addStyleTag({ content: measurementCss.join('\n') }) + } + await installSyntheticVisibleSpinners( + page, + options.syntheticVisibleSpinners, + options.syntheticSpinnerAnimation, + options.syntheticSpinnerSteps + ) + await page.evaluate(async (repoPath) => { + await window.api.repos.add({ path: repoPath }) + const store = window.__store + await store?.getState().fetchRepos() + const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath) + if (repo) { + await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' }) + await store.getState().fetchWorktrees(repo.id) + } + }, repoDir) + await page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 60_000 } + ) + console.log( + `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}` + ) + await sleep(options.warmupMs) + const rendererIdleState = await collectRendererIdleState(page) + const deadline = Date.now() + options.sampleMs + const samples = [] + let previousSnapshot = null + while (Date.now() <= deadline || samples.length === 0) { + const sampledAt = Date.now() + const processRows = descendantsOf(readProcessRows(), rootPid) + const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) })) + if (previousSnapshot) { + const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000) + const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc])) + const processes = rawProcesses.map((row) => { + const previous = previousByPid.get(row.pid) + const canComputeDelta = + typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number' + const cpu = canComputeDelta + ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100) + : row.percentCpu + return { ...row, cpu } + }) + samples.push({ + at: sampledAt, + elapsedMs: sampledAt - previousSnapshot.at, + totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0), + totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0), + processes + }) + } + previousSnapshot = { at: sampledAt, processes: rawProcesses } + await sleep(options.intervalMs) + } + const report = { + benchmark: 'orca-idle-cpu', + createdAt: new Date().toISOString(), + options, + rootPid, + platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length }, + rendererIdleState, + sampleCount: samples.length, + summary: summarizeSamples(samples), + processInventory: summarizeProcessInventory(samples), + samples + } + if (options.output) { + mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }) + writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`) + console.log(`[idle-cpu] wrote ${options.output}`) + } + console.log( + JSON.stringify( + { + summary: report.summary, + processInventory: report.processInventory, + sampleCount: report.sampleCount + }, + null, + 2 + ) + ) + } finally { + const launchedProcesses = descendantsOf(readProcessRows(), rootPid).filter( + (proc) => proc.pid !== rootPid + ) + await app.close().catch(() => undefined) + await sleep(250) + terminateProcesses(launchedProcesses) + rmSync(userDataDir, { recursive: true, force: true }) + for (const dir of cleanupDirs) { + rmSync(dir, { recursive: true, force: true }) + } + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/package.json b/package.json index 5ba4f27d194..7e7f3f26265 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", - "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts" + "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", + "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs" }, "dependencies": { "@electron-toolkit/preload": "^3.0.2", diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 9b607c40c31..d92e7a3382a 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -49,7 +49,6 @@ import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' -import { warmSystemFontFamilies } from '../system-fonts' import { registerUpdaterHandlers } from '../window/attach-main-window-services' import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers' import type { ClaudeUsageStore } from '../claude-usage/store' @@ -163,5 +162,4 @@ export function registerCoreHandlers( registerClipboardHandlers() registerUpdaterHandlers(store) registerSpeechHandlers(store) - warmSystemFontFamilies() } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 8ff278bde71..fe7db2437c2 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -195,7 +195,7 @@ describe('RateLimitService', () => { } }) - it('can defer the startup fetch until the attached window becomes active', async () => { + it('does not turn the initial window activation into a hidden startup quota fetch', async () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) const service = new RateLimitService() @@ -205,14 +205,17 @@ describe('RateLimitService', () => { service.start({ fetchImmediately: false }) await Promise.resolve() + window.emit('show') + window.emit('focus') + window.emit('restore') + await Promise.resolve() + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() expect(fetchCodexRateLimits).not.toHaveBeenCalled() - window.emit('show') + await service.refresh() - await vi.waitFor(() => { - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) service.stop() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 827bce991e7..14f0693ee1c 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -80,6 +80,7 @@ export class RateLimitService { private codexOnlyFetchQueued = false private claudeOnlyFetchQueued = false private fetchIdleResolvers: (() => void)[] = [] + private hasCompletedFetch = false private codexFetchGeneration = 0 private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 @@ -556,6 +557,9 @@ export class RateLimitService { if (!this.shouldBackgroundPoll()) { return } + if (!this.hasCompletedFetch) { + return + } if (Date.now() - this.lastFetchAt < MIN_REFETCH_MS) { return } @@ -592,6 +596,7 @@ export class RateLimitService { } } } finally { + this.hasCompletedFetch = true this.isFetching = false this.resolveFetchIdleWaiters() } @@ -627,6 +632,7 @@ export class RateLimitService { } } } finally { + this.hasCompletedFetch = true this.isFetching = false this.resolveFetchIdleWaiters() } @@ -662,6 +668,7 @@ export class RateLimitService { } } } finally { + this.hasCompletedFetch = true this.isFetching = false this.resolveFetchIdleWaiters() } diff --git a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx index af2a8e97148..eec4c82bb39 100644 --- a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx +++ b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx @@ -53,6 +53,7 @@ describe('CmdJPaletteFeatureTipVisual', () => { expect(html).toContain('auth-redirect') expect(html).not.toContain('payments-api') expect(html).not.toContain('animate-spin') + expect(html).not.toContain('animate-cmd-j-tip-caret') expect(html).not.toContain('animate-cmd-j-tip-result-in') }) @@ -83,6 +84,31 @@ describe('CmdJPaletteFeatureTipVisual', () => { expect(clearTimeoutSpy).toHaveBeenCalled() }) + it('settles after the one-shot demo instead of looping idle timers', async () => { + vi.useFakeTimers() + + const { container, root } = await renderVisual() + await act(async () => { + vi.runAllTimers() + }) + + expect(container.textContent).toContain('auth') + expect(container.textContent).toContain('auth-redirect') + expect(container.textContent).not.toContain('payments-api') + expect(vi.getTimerCount()).toBe(0) + + await act(async () => { + root.unmount() + }) + }) + + it('does not render infinite animation classes in the default preview', () => { + const html = renderToStaticMarkup() + + expect(html).not.toContain('animate-spin') + expect(html).not.toContain('animate-cmd-j-tip-caret') + }) + it('falls back to default per-key chips when the live binding is unassigned', () => { shortcutKeysMock.mockReturnValue([]) formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J']) diff --git a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx index ff42fe0cb04..080e54b909b 100644 --- a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx +++ b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx @@ -34,13 +34,7 @@ function filterDemoWorktrees(query: string): typeof DEMO_WORKTREES { // Why: cycle phases are sequenced so the keypress visibly precedes the palette // opening (cause → effect), matching what the user will see when they actually // press the shortcut. -type CyclePhase = 'idle' | 'pressed' | 'open' | 'typing' | 'closing' - -type ClosingFrame = { - query: string - worktrees: ReturnType - showCreate: boolean -} +type CyclePhase = 'idle' | 'pressed' | 'open' | 'typing' const KEYPRESS_AT_MS = 450 const PALETTE_OPEN_AT_MS = 850 @@ -50,12 +44,6 @@ const HOLD_BEFORE_TYPING_MS = 700 // Per-character typing interval. Kept tight and constant so the cursor advances // at an even cadence instead of feeling staggered. const TYPE_INTERVAL_MS = 120 -// Pause on the final, filtered state before the cycle resets, so the -// user has time to actually read the matched worktrees + create option. -const HOLD_AFTER_RESULTS_MS = 3200 -// Matches the palette container's `duration-300` fade plus a small buffer so we -// never swap list content while the closing fade is still running. -const PALETTE_FADE_OUT_MS = 350 export function CmdJPaletteFeatureTipVisual(): JSX.Element { const reducedMotion = usePrefersReducedMotion() @@ -69,7 +57,6 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { const [phase, setPhase] = useState('idle') const [typedLength, setTypedLength] = useState(0) - const [closingFrame, setClosingFrame] = useState(null) // Why: for reduced-motion users, jump straight to the fully-populated end // state so they see what the feature does without any animation. @@ -78,23 +65,16 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { const currentQuery = TYPED_QUERY.slice(0, effectiveTypedLength) const visibleWorktrees = filterDemoWorktrees(currentQuery) const showCreateAction = currentQuery.trim().length > 0 - // Why: snapshot the final filtered frame during `closing` so loop reset never - // re-renders the empty-query worktree list while the palette is still visible. - const renderQuery = phase === 'closing' && closingFrame ? closingFrame.query : currentQuery - const renderWorktrees = - phase === 'closing' && closingFrame ? closingFrame.worktrees : visibleWorktrees - const renderShowCreate = - phase === 'closing' && closingFrame ? closingFrame.showCreate : showCreateAction + const renderQuery = currentQuery + const renderWorktrees = visibleWorktrees + const renderShowCreate = showCreateAction // Why: mirror WorktreeJumpPalette — recent worktrees render as soon as the - // palette opens; typing only filters them down. Keep the list mounted through - // `closing` so the final filtered frame fades out with the palette. - const showWorktreeList = - reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing' - // Why: keep the palette hidden during `pressed` — an empty search shell between - // cycles read as the pre-search list flashing back before the fade finished. - const paletteMounted = - reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing' - const paletteOpaque = reducedMotion || (paletteMounted && phase !== 'closing') + // palette opens; typing only filters them down. + const showWorktreeList = reducedMotion || phase === 'open' || phase === 'typing' + // Why: keep the palette hidden during `pressed` so the keypress visibly + // precedes the palette opening. + const paletteMounted = reducedMotion || phase === 'open' || phase === 'typing' + const paletteOpaque = reducedMotion || paletteMounted const resultEnterClass = showWorktreeList && !reducedMotion && phase === 'open' ? 'animate-cmd-j-tip-result-in' : '' @@ -118,45 +98,27 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { i += 1 setTypedLength(i) if (i >= TYPED_QUERY.length) { - later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS) return } timeouts.push(window.setTimeout(typeNext, TYPE_INTERVAL_MS)) } if (i >= TYPED_QUERY.length) { - later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS) return } later(typeNext, TYPE_INTERVAL_MS) } - const scheduleCycle = (): void => { - later(() => setPhase('pressed'), KEYPRESS_AT_MS) - later(() => setPhase('open'), PALETTE_OPEN_AT_MS) - later(() => { - setPhase('typing') - startTyping(0) - }, PALETTE_OPEN_AT_MS + HOLD_BEFORE_TYPING_MS) - } - - const closeAndRestart = (): void => { - setClosingFrame({ - query: TYPED_QUERY, - worktrees: filterDemoWorktrees(TYPED_QUERY), - showCreate: true - }) - setPhase('closing') - later(() => { - setPhase('idle') - setClosingFrame(null) - setTypedLength(0) - scheduleCycle() - }, PALETTE_FADE_OUT_MS) - } + // Why: this tip may remain open while Orca is idle. Play the demo once, + // then settle on the final useful state instead of looping timers forever. + later(() => setPhase('pressed'), KEYPRESS_AT_MS) + later(() => setPhase('open'), PALETTE_OPEN_AT_MS) + later(() => { + setPhase('typing') + startTyping(0) + }, PALETTE_OPEN_AT_MS + HOLD_BEFORE_TYPING_MS) setPhase('idle') setTypedLength(0) - scheduleCycle() return () => { cancelled = true @@ -210,7 +172,9 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { {renderQuery} {!reducedMotion && (phase === 'open' || phase === 'typing') ? ( - + // Why: this tip can sit open while Orca is idle; keep the + // caret static so the preview does not wake the compositor. + ) : null} @@ -227,14 +191,9 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { {result.status === 'done' ? (