Reduce Orca idle CPU (#5402)

* Reduce Orca idle CPU

Co-authored-by: Orca <help@stably.ai>

* Restore low-cost working spinner

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-15 12:56:22 -07:00
committed by GitHub
co-authored by Orca
parent 4880b56998
commit 5bf24368c0
10 changed files with 709 additions and 78 deletions
@@ -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)
}
+591
View File
@@ -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 <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> 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 <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> 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)
})
+2 -1
View File
@@ -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",
-2
View File
@@ -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()
}
+8 -5
View File
@@ -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()
+7
View File
@@ -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()
}
@@ -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(<CmdJPaletteFeatureTipVisual />)
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'])
@@ -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<typeof filterDemoWorktrees>
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<CyclePhase>('idle')
const [typedLength, setTypedLength] = useState(0)
const [closingFrame, setClosingFrame] = useState<ClosingFrame | null>(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 {
<span className="block truncate">
{renderQuery}
{!reducedMotion && (phase === 'open' || phase === 'typing') ? (
<span className="ml-px inline-block h-[14px] w-px -translate-y-px align-middle bg-foreground/75 animate-cmd-j-tip-caret" />
// Why: this tip can sit open while Orca is idle; keep the
// caret static so the preview does not wake the compositor.
<span className="ml-px inline-block h-[14px] w-px -translate-y-px align-middle bg-foreground/75" />
) : null}
</span>
</div>
@@ -227,14 +191,9 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
{result.status === 'done' ? (
<span className="size-2.5 rounded-full bg-emerald-500" aria-hidden="true" />
) : (
// Why: yellow border spinner mirrors StatusIndicator's
// 'working' affordance, so users connect the icon to the
// same running-workspace state they see in the sidebar.
<span
className={`block size-2.5 rounded-full border-[1.5px] border-yellow-500 ${
reducedMotion ? 'border-t-yellow-500' : 'animate-spin border-t-transparent'
}`}
/>
// Why: this tip can stay mounted while Orca is idle; mirror
// the sidebar's static working ring instead of spinning.
<span className="block size-2.5 rounded-full border-[1.5px] border-yellow-500 bg-yellow-500/15" />
)}
</span>
<div className="min-w-0 flex-1">
@@ -17,12 +17,13 @@ function renderDotClassNames(status: Status): string[] {
}
describe('StatusIndicator', () => {
it('renders working as a yellow spinner', () => {
it('renders working as a stepped yellow spinner', () => {
const classNames = renderDotClassNames('working')
expect(classNames).toContain('border-yellow-500')
expect(classNames).toContain('border-t-transparent')
expect(classNames).toContain('animate-spin')
expect(classNames).toContain('[animation:spin_1s_steps(12,end)_infinite]')
expect(classNames).not.toContain('animate-spin')
})
it('renders permission as an amber attention dot', () => {
@@ -33,7 +33,9 @@ const StatusIndicator = React.memo(function StatusIndicator({
title={resolvedTitle}
{...rest}
>
<span className="block size-2 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin" />
{/* Why: a stepped spin preserves the worker-is-running affordance while
avoiding a full-refresh-rate compositor loop for long agent runs. */}
<span className="block size-2 rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]" />
</span>
)
}
@@ -52,8 +54,7 @@ const StatusIndicator = React.memo(function StatusIndicator({
: status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Working uses a yellow
// spinner so working vs done differ by motion; 'inactive'
// stays grey.
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
)}