diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index c71449429..8e3be87a6 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -28,6 +28,7 @@ const { getProcessStartedAtMsMock, daemonClientMock, spawnerInstances, + ensureRunningOverrides, adapterInstances, setLocalPtyProviderMock, unbindLocalProviderListenersMock, @@ -83,6 +84,7 @@ const { // Why: every DaemonSpawner constructed under test pushes into this array so // assertions can check "was the *same* spawner reused across restart?". const spawnerInstances: MockSpawner[] = [] + const ensureRunningOverrides: (() => Promise<{ socketPath: string; tokenPath: string }>)[] = [] // Same for DaemonPtyAdapter. The test asserts the replacement adapter is a // fresh instance whose respawn closure targets the *original* spawner. const adapterInstances: MockAdapter[] = [] @@ -107,6 +109,7 @@ const { getProcessStartedAtMsMock, daemonClientMock, spawnerInstances, + ensureRunningOverrides, adapterInstances, setLocalPtyProviderMock, unbindLocalProviderListenersMock, @@ -193,6 +196,10 @@ vi.mock('./daemon-spawner', () => ({ // so the test can verify the *replacement* adapter is constructed with // info from the second ensureRunning call, not stale info from the first. this.ensureRunning = vi.fn(async () => { + const override = ensureRunningOverrides.shift() + if (override) { + return override() + } this.socketCounter += 1 return { socketPath: `/fake/socket-${this.socketCounter}`, @@ -256,6 +263,7 @@ vi.mock('../ipc/pty', () => ({ async function importFresh() { vi.resetModules() spawnerInstances.length = 0 + ensureRunningOverrides.length = 0 adapterInstances.length = 0 setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() @@ -291,6 +299,44 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { vi.clearAllMocks() }) + it('re-binds listeners after the first daemon provider is installed', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1) + expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(1) + expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan( + setLocalPtyProviderMock.mock.invocationCallOrder[0] + ) + }) + + it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { + const mod = await importFresh() + let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void + ensureRunningOverrides.push( + () => + new Promise((resolve) => { + resolveEnsureRunning = resolve + }) + ) + const abortController = new AbortController() + + const started = mod.initDaemonPtyProvider(abortController.signal) + await Promise.resolve() + + expect(spawnerInstances).toHaveLength(1) + expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1) + + abortController.abort() + resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) + await started + + expect(adapterInstances).toHaveLength(0) + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled() + expect(mod.getDaemonProvider()).toBeNull() + }) + it('fans pty:exit for every active session *before* unbinding listeners, and killedCount is captured pre-fanout', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 904b034e0..cd9e335bc 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -329,7 +329,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } } -export async function initDaemonPtyProvider(): Promise { +export async function initDaemonPtyProvider(signal?: AbortSignal): Promise { const runtimeDir = getRuntimeDir() const newSpawner = new DaemonSpawner({ @@ -341,6 +341,11 @@ export async function initDaemonPtyProvider(): Promise { // throws, a stale spawner would prevent shutdownDaemon() from cleaning up // correctly on retry. const info = await newSpawner.ensureRunning() + if (signal?.aborted) { + // Why: startup fail-open may already have allowed fallback LocalPtyProvider + // PTYs to spawn. A late daemon swap would strand those PTYs on the old owner. + return + } const newAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, @@ -368,10 +373,19 @@ export async function initDaemonPtyProvider(): Promise { if (routedAdapter instanceof DaemonPtyRouter) { await routedAdapter.discoverLegacySessions() } + if (signal?.aborted) { + // Why: same late-swap guard after legacy discovery, which can also exceed + // the first-window startup timeout on slow or stale daemon state. + return + } spawner = newSpawner adapter = routedAdapter setLocalPtyProvider(routedAdapter) + // Why: desktop startup now lets the first window register PTY listeners + // before daemon init finishes. Rebind here so daemon PTYs still fan out + // data/exit events through the renderer and runtime listeners. + rebindLocalProviderListeners() } // Why: the Manage Sessions IPC handlers need read access to the current diff --git a/src/main/index.ts b/src/main/index.ts index ebf99facf..678ac7ad1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ import { grantDirAcl } from './win32-utils' import { existsSync } from 'fs' import { join } from 'path' import os from 'node:os' -import { app, BrowserWindow, nativeTheme } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' import { Store, initDataPath } from './persistence' @@ -165,6 +165,8 @@ let watcherShutdownDone = false let automations: AutomationService | null = null let keybindings: KeybindingService | null = null let expectedRendererReload: { webContentsId: number; until: number } | null = null +let firstWindowStartupServicesReady: Promise = Promise.resolve() +let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ @@ -399,6 +401,41 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } +ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { + await firstWindowStartupServicesReady +}) + +function startDesktopFirstWindowStartupServices(): Promise { + const startupServices = startFirstWindowStartupServices({ + // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` + // registers its PTY runtime separately and must not spawn the desktop daemon + // or hook loopback listener. + startDaemonPtyProvider: (signal) => initDaemonPtyProvider(signal), + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so + // the renderer awaits this barrier before restored terminals reconnect. + startAgentHookServer: () => + agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + // Why: hooks source this endpoint file at invocation time, so old PTY + // env still reaches the current Orca process after an app restart. + // Dev uses a namespace because all worktrees share `orca-dev`. + userDataPath: app.getPath('userData'), + endpointNamespace: devAgentHookEndpointNamespace + }), + onDaemonError: (error) => { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + }, + onAgentHookServerError: (error) => { + // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar + // enrichment only. Orca must still boot if the loopback receiver fails. + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + firstWindowStartupServicesReady = startupServices.firstWindowReady + localPtyStartupReady = startupServices.localPtyReady + return firstWindowStartupServicesReady +} + function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) @@ -582,6 +619,7 @@ function openMainWindow(): BrowserWindow { prepareCodexRuntimeHomeForLaunch, (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { + awaitLocalPtyStartup: () => localPtyStartupReady, onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { if (window.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) @@ -591,7 +629,10 @@ function openMainWindow(): BrowserWindow { } ) rateLimits.attach(window) - rateLimits.start() + // Why: quota probes can spawn CLIs and hit network. The attached show/focus + // listeners refresh as soon as the window can present quota UI, so do not + // compete with first paint. + rateLimits.start({ fetchImmediately: false }) window.on('closed', () => { if (mainWindow === window) { mainWindow = null @@ -1347,31 +1388,7 @@ app.whenReady().then(async () => { registerMobileHandlers(runtimeRpc) if (!isServeMode) { - await startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless - // `orca serve` registers its PTY runtime below and must not spawn the - // desktop daemon or hook loopback listener. - startDaemonPtyProvider: () => initDaemonPtyProvider(), - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, - // so the hook server must start before restored terminals can mount. - startAgentHookServer: () => - agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time, so old PTY - // env still reaches the current Orca process after an app restart. - // Dev uses a namespace because all worktrees share `orca-dev`. - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }), - onDaemonError: (error) => { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - }, - onAgentHookServerError: (error) => { - // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar - // enrichment only. Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) + startDesktopFirstWindowStartupServices() } if (serveOptions) { @@ -1395,8 +1412,9 @@ app.whenReady().then(async () => { return } - // Why: once the hook server is ready (or has already failed open), window - // creation and runtime RPC startup are independent. + // Why: window creation and runtime RPC startup are independent. Local PTY + // spawns are gated inside registerPtyHandlers so RPC can bind immediately + // without racing the daemon provider swap. const [win] = await Promise.all([ Promise.resolve(openMainWindow()), runtimeRpc.start().catch((error) => { diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts new file mode 100644 index 000000000..0de91770d --- /dev/null +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('PTY startup barrier ordering', () => { + it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { + const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8') + const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') + const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) + const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) + const rendererSpawnStart = source.indexOf("ipcMain.handle(\n 'pty:spawn'") + const rendererSpawnEnd = source.indexOf("ipcMain.handle(\n 'pty:kill'", rendererSpawnStart) + const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) + + for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { + const barrierIndex = spawnBlock.indexOf( + 'const startupPromise = getLocalPtyStartupPromise(args.connectionId)' + ) + const providerIndex = spawnBlock.indexOf('const provider = getProvider(args.connectionId)') + + expect(barrierIndex).toBeGreaterThanOrEqual(0) + expect(providerIndex).toBeGreaterThanOrEqual(0) + expect(barrierIndex).toBeLessThan(providerIndex) + } + }) +}) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d95853f29..51e3cf15a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -157,6 +157,7 @@ import { hasPendingRendererSerializerForPaneKey, setPtyOwnership, setLocalPtyProvider, + rebindLocalProviderListeners, unregisterSshPtyProvider } from './pty' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' @@ -181,6 +182,14 @@ function makeDisposable() { return { dispose: vi.fn() } } +function makeDeferred() { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + describe('registerPtyHandlers', () => { const handlers = new Map unknown>() const mainWindow = { @@ -383,6 +392,78 @@ describe('registerPtyHandlers', () => { return writeCall[1] as (event: unknown, args: { id: string; data: string }) => void } + function installDaemonTestProvider() { + const spawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'daemon-pty' + })) + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + return spawn + } + + function installObservableDaemonTestProvider() { + const spawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'daemon-pty' + })) + let dataHandler: ((payload: { id: string; data: string }) => void) | null = null + let exitHandler: ((payload: { id: string; code: number }) => void) | null = null + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => { + dataHandler = handler + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((handler: (payload: { id: string; code: number }) => void) => { + exitHandler = handler + return () => {} + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + return { + spawn, + emitData: (id: string, data: string) => dataHandler?.({ id, data }), + emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) + } + } + function getPtyAckDataListener(): ( event: unknown, args: { id: string; charCount: number } @@ -2273,6 +2354,186 @@ describe('registerPtyHandlers', () => { ) }) + it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => { + const barrier = makeDeferred() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { + awaitLocalPtyStartup: () => barrier.promise + } + ) + + const pendingSpawn = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + }) as Promise<{ id: string }> + + await Promise.resolve() + expect(spawnMock).not.toHaveBeenCalled() + + const daemonSpawn = installDaemonTestProvider() + barrier.resolve() + const result = await pendingSpawn + + expect(daemonSpawn).toHaveBeenCalledTimes(1) + expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rebinds local data and exit listeners after a late daemon provider install', async () => { + vi.useFakeTimers() + const barrier = makeDeferred() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 13), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { + awaitLocalPtyStartup: () => barrier.promise + } + ) + + const pendingSpawn = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + }) as Promise<{ id: string }> + await Promise.resolve() + + const daemon = installObservableDaemonTestProvider() + rebindLocalProviderListeners() + barrier.resolve() + const result = await pendingSpawn + + daemon.emitData(result.id, 'daemon output') + vi.advanceTimersByTime(8) + daemon.emitExit(result.id, 0) + + expect(daemon.spawn).toHaveBeenCalledTimes(1) + expect(runtime.onPtyData).toHaveBeenCalledWith(result.id, 'daemon output', expect.any(Number)) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: result.id, + data: 'daemon output', + seq: 13, + rawLength: 'daemon output'.length + }) + expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: result.id, + code: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => { + const barrier = makeDeferred() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { + awaitLocalPtyStartup: () => barrier.promise + } + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn: (args: { cols: number; rows: number; env?: Record }) => Promise<{ + id: string + }> + } + + const pendingSpawn = controller.spawn({ cols: 80, rows: 24, env: {} }) + + await Promise.resolve() + expect(spawnMock).not.toHaveBeenCalled() + + const daemonSpawn = installDaemonTestProvider() + barrier.resolve() + const result = await pendingSpawn + + expect(daemonSpawn).toHaveBeenCalledTimes(1) + expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('does not wait on the desktop startup barrier for SSH spawns', async () => { + const barrier = makeDeferred() + const awaitLocalPtyStartup = vi.fn(() => barrier.promise) + const sshSpawn = vi.fn(async () => ({ id: 'remote-pty' })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyStartup } + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-1', + env: {} + }) + ).resolves.toEqual(expect.objectContaining({ id: 'remote-pty' })) + + expect(awaitLocalPtyStartup).not.toHaveBeenCalled() + expect(sshSpawn).toHaveBeenCalledTimes(1) + }) + it('lists sessions from both local and SSH providers', async () => { registerPtyHandlers(mainWindow as never) const sshListProcesses = vi.fn(async () => [ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index af877be6c..2838df9be 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1004,8 +1004,21 @@ export function registerPtyHandlers( getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store + store?: Store, + options?: { + awaitLocalPtyStartup?: () => Promise + } ): void { + const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { + if (connectionId) { + return undefined + } + // Why: during desktop cold start the daemon provider swap now overlaps + // first paint. Local spawns must wait before resolving getProvider(), while + // SSH/headless paths do not use the desktop daemon. + return options?.awaitLocalPtyStartup?.() + } + // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('pty:spawn') @@ -1561,6 +1574,10 @@ export function registerPtyHandlers( // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. runtime?.setPtyController({ spawn: async (args) => { + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2009,6 +2026,10 @@ export function registerPtyHandlers( } } ) => { + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 0c2606a77..8ff278bde 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -195,6 +195,29 @@ describe('RateLimitService', () => { } }) + it('can defer the startup fetch until the attached window becomes active', async () => { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + await Promise.resolve() + + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + + window.emit('show') + + await vi.waitFor(() => { + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + }) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 67f564b54..827bce991 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -189,9 +189,10 @@ export class RateLimitService { this.detachWindowListeners = detachWindowListeners } - start(): void { - // Fire initial fetch immediately on start - void this.fetchAll() + start(options: { fetchImmediately?: boolean } = {}): void { + if (options.fetchImmediately !== false) { + void this.fetchAll() + } this.startTimer() } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts new file mode 100644 index 000000000..cd1faf35c --- /dev/null +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('desktop startup ordering', () => { + it('passes the startup barrier into PTY handlers without blocking window creation', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const attachStart = source.indexOf('attachMainWindowServices(') + const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) + const attachBlock = source.slice(attachStart, attachEnd) + const desktopStart = source.indexOf('const [win] = await Promise.all([') + const desktopEnd = source.indexOf('// Why: the macOS notification permission dialog') + const desktopStartup = source.slice(desktopStart, desktopEnd) + + expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') + expect(source).toContain('firstWindowStartupServicesReady = startupServices.firstWindowReady') + expect(source).toContain('localPtyStartupReady = startupServices.localPtyReady') + + const windowIndex = desktopStartup.indexOf('Promise.resolve(openMainWindow())') + const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') + const legacyRpcStartIndex = desktopStartup.indexOf('runtimeRpc.start()') + + expect(windowIndex).toBeGreaterThanOrEqual(0) + expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) + }) + + it('does not run the rate-limit quota fetch before the first window can show results', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const attachIndex = source.indexOf('rateLimits.attach(window)') + const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') + + expect(attachIndex).toBeGreaterThanOrEqual(0) + expect(startIndex).toBeGreaterThan(attachIndex) + }) +}) diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index 6faf7d8d9..640efd649 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -29,7 +29,7 @@ describe('startFirstWindowStartupServices', () => { expect(events).toEqual(['daemon-started', 'hooks-started']) let completed = false - started.then(() => { + started.firstWindowReady.then(() => { completed = true }) @@ -38,7 +38,8 @@ describe('startFirstWindowStartupServices', () => { expect(completed).toBe(false) resolveHooks() - await started + await started.firstWindowReady + await started.localPtyReady expect(completed).toBe(true) }) @@ -46,37 +47,67 @@ describe('startFirstWindowStartupServices', () => { const onDaemonError = vi.fn() const onAgentHookServerError = vi.fn() - await expect( - startFirstWindowStartupServices({ - startDaemonPtyProvider: () => Promise.reject(new Error('daemon failed')), - startAgentHookServer: () => Promise.reject(new Error('hooks failed')), - onDaemonError, - onAgentHookServerError - }) - ).resolves.toBeUndefined() + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => Promise.reject(new Error('daemon failed')), + startAgentHookServer: () => Promise.reject(new Error('hooks failed')), + onDaemonError, + onAgentHookServerError + }) + + await expect(started.firstWindowReady).resolves.toBeUndefined() + await expect(started.localPtyReady).resolves.toBeUndefined() expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error)) }) - it('fails open when a pre-window startup service hangs', async () => { - vi.useFakeTimers() + it('logs synchronous service startup failures and still resolves the startup barrier', async () => { const onDaemonError = vi.fn() const onAgentHookServerError = vi.fn() + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => { + throw new Error('daemon sync failed') + }, + startAgentHookServer: () => { + throw new Error('hooks sync failed') + }, + onDaemonError, + onAgentHookServerError + }) + + await expect(started.firstWindowReady).resolves.toBeUndefined() + await expect(started.localPtyReady).resolves.toBeUndefined() + + expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) + expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error)) + }) + + it('fails open the first window and local PTY startup while aborting hung services', async () => { + vi.useFakeTimers() + const onDaemonError = vi.fn() + const onAgentHookServerError = vi.fn() + let daemonSignal: AbortSignal | undefined + try { const started = startFirstWindowStartupServices({ - startDaemonPtyProvider: () => new Promise(() => {}), + startDaemonPtyProvider: (signal) => { + daemonSignal = signal + return new Promise(() => {}) + }, startAgentHookServer: () => Promise.resolve(), onDaemonError, onAgentHookServerError }) + await Promise.resolve() await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) - await expect(started).resolves.toBeUndefined() + await expect(started.firstWindowReady).resolves.toBeUndefined() + await expect(started.localPtyReady).resolves.toBeUndefined() expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) expect(onAgentHookServerError).not.toHaveBeenCalled() + expect(daemonSignal?.aborted).toBe(true) } finally { vi.useRealTimers() } diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 0f7f1ed23..653eda3a2 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -1,33 +1,50 @@ type FirstWindowStartupServices = { - startDaemonPtyProvider: () => Promise - startAgentHookServer: () => Promise + startDaemonPtyProvider: (signal: AbortSignal) => Promise + startAgentHookServer: (signal: AbortSignal) => Promise onDaemonError: (error: unknown) => void onAgentHookServerError: (error: unknown) => void } +type StartupService = { + ready: Promise + reportTimeout: () => void +} + +type FirstWindowStartupServicesResult = { + firstWindowReady: Promise + localPtyReady: Promise +} + export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000 -async function startServiceWithTimeout( +function startService( label: string, - start: () => Promise, + start: (signal: AbortSignal) => Promise, onError: (error: unknown) => void -): Promise { - let timeout: ReturnType | null = null - try { - const startPromise = start() - await Promise.race([ - startPromise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - reject(new Error(`${label} startup timed out`)) - }, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) - }) - ]) - } catch (error) { - onError(error) - } finally { - if (timeout) { - clearTimeout(timeout) +): StartupService { + const abortController = new AbortController() + let settled = false + let reportedTimeout = false + const ready = Promise.resolve() + .then(() => start(abortController.signal)) + .catch((error) => { + if (!reportedTimeout) { + onError(error) + } + }) + .finally(() => { + settled = true + }) + + return { + ready, + reportTimeout: () => { + if (settled) { + return + } + reportedTimeout = true + abortController.abort() + onError(new Error(`${label} startup timed out`)) } } } @@ -35,18 +52,41 @@ async function startServiceWithTimeout( /** * Starts the services that must be ready before restored terminal panes mount. */ -export async function startFirstWindowStartupServices({ +export function startFirstWindowStartupServices({ startDaemonPtyProvider, startAgentHookServer, onDaemonError, onAgentHookServerError -}: FirstWindowStartupServices): Promise { +}: FirstWindowStartupServices): FirstWindowStartupServicesResult { // Why: daemon startup and hook-server binding are independent, but both gate // restored terminals; run them together so cold-start latency is max(), not sum(). - // They are also fail-open services: a wedged daemon/hook startup must not - // prevent the first BrowserWindow from existing. - await Promise.all([ - startServiceWithTimeout('daemon PTY provider', startDaemonPtyProvider, onDaemonError), - startServiceWithTimeout('agent hook server', startAgentHookServer, onAgentHookServerError) + // The first window and local PTY startup both fail open after the timeout. + // The timeout also aborts slow services so late daemon swaps cannot strand + // any fallback LocalPtyProvider PTYs that spawn after the barrier opens. + const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError) + const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError) + const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined) + let timeout: ReturnType | null = null + let resolveTimedOut!: () => void + const timedOut = new Promise((resolve) => { + resolveTimedOut = resolve + }) + const firstWindowReady = Promise.race([ + allServicesReady.finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }), + new Promise((resolve) => { + timeout = setTimeout(() => { + daemon.reportTimeout() + hooks.reportTimeout() + resolveTimedOut() + resolve() + }, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + }) ]) + const localPtyReady = Promise.race([allServicesReady, timedOut]) + + return { firstWindowReady, localPtyReady } } diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 210cd7268..7be18282a 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -14,6 +14,7 @@ const { registerRepoHandlersMock, registerWorktreeHandlersMock, registerPtyHandlersMock, + hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, browserManagerUnregisterAllMock } = vi.hoisted(() => ({ @@ -29,6 +30,7 @@ const { registerRepoHandlersMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), + hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), browserManagerUnregisterAllMock: vi.fn() })) @@ -66,6 +68,10 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../memory/hydrate-local-pty-registry', () => ({ + hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock +})) + vi.mock('../browser/browser-manager', () => ({ browserManager: { unregisterAll: browserManagerUnregisterAllMock @@ -140,6 +146,14 @@ function createRuntime(): RuntimeStub { } } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { return mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -160,6 +174,7 @@ describe('attachMainWindowServices', () => { registerRepoHandlersMock.mockReset() registerWorktreeHandlersMock.mockReset() registerPtyHandlersMock.mockReset() + hydrateLocalPtyRegistryAtBootMock.mockReset() setupAutoUpdaterMock.mockReset() browserManagerUnregisterAllMock.mockReset() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) @@ -192,6 +207,30 @@ describe('attachMainWindowServices', () => { expect(mainWindow.webContents.reload).toHaveBeenCalledTimes(1) }) + it('retries local PTY registry hydration after local startup services are ready', async () => { + const localStartup = deferred() + const store = createStore() + + attachMainWindowServices( + createMainWindow() as never, + store, + createRuntime() as never, + undefined, + undefined, + { awaitLocalPtyStartup: () => localStartup.promise } + ) + + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(1) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) + + localStartup.resolve() + await localStartup.promise + await Promise.resolve() + + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(2) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenLastCalledWith(store) + }) + it('ignores app reload requests from non-main webContents', async () => { const onBeforeRendererReload = vi.fn() const mainWindow = createMainWindow() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4b03bcdd1..e419db70a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -51,6 +51,7 @@ export function attachMainWindowServices( target?: ClaudeAccountSelectionTarget ) => Promise, options?: { + awaitLocalPtyStartup?: () => Promise onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void } ): void { @@ -64,7 +65,10 @@ export function attachMainWindowServices( getSelectedCodexHomePath, () => store.getSettings(), prepareClaudeAuth, - store + store, + { + awaitLocalPtyStartup: options?.awaitLocalPtyStartup + } ) // Why: the Manage Sessions settings panel (docs/daemon-staleness-ux.md §Phase 1) // uses a narrow `pty:management:*` IPC surface that reads the live @@ -89,6 +93,17 @@ export function attachMainWindowServices( // function re-runs as the main window is recreated — does not redo the // git I/O or daemon RPC. void hydrateLocalPtyRegistryAtBoot(store) + const localPtyStartupReady = options?.awaitLocalPtyStartup?.() + if (localPtyStartupReady) { + void localPtyStartupReady + .then(() => hydrateLocalPtyRegistryAtBoot(store)) + .catch((error) => { + console.warn( + '[memory] Deferred pty-registry hydration skipped:', + error instanceof Error ? error.message : String(error) + ) + }) + } registerSshHandlers(store, () => mainWindow, runtime) registerRemoteWorkspaceHandlers(store, () => mainWindow) registerFileDropRelay(mainWindow) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2097a5f82..e5f6ced12 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -678,6 +678,9 @@ export type AppApi = { /** Reloads the current app renderer through main so expected renderer * teardown can be classified before Electron emits process-gone events. */ reload: () => Promise + /** Resolves when the daemon PTY provider and hook receiver have either + * started or failed open for the first BrowserWindow. */ + awaitFirstWindowStartupServices: () => Promise /** Returns the macOS `AppleCurrentKeyboardLayoutInputSourceID` when * available (e.g. `com.apple.keylayout.PolishPro`). Used by the * keyboard-layout probe to distinguish layouts whose base layer matches diff --git a/src/preload/index.ts b/src/preload/index.ts index 5700305a9..d0fd09305 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -405,6 +405,8 @@ const api = { } }, reload: (): Promise => ipcRenderer.invoke('app:reload'), + awaitFirstWindowStartupServices: (): Promise => + ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US // Extended / ABC Extended / IME Roman modes from plain US QWERTY (see diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 499673ad2..6246d80c5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,23 +39,15 @@ import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' import Sidebar from './components/Sidebar' -import Terminal from './components/Terminal' import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' import RightSidebar from './components/right-sidebar' -import { StatusBar } from './components/status-bar/StatusBar' -import { UpdateCard } from './components/UpdateCard' import { StarNagCard } from './components/StarNagCard' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' -import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' -import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' -import { - FloatingTerminalPanel, - FloatingTerminalToggleButton -} from './components/floating-terminal/FloatingTerminalPanel' +import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, @@ -64,7 +56,6 @@ import { shouldMinimizeFloatingWorkspacePanelOnCloseShortcut } from '@/lib/floating-workspace-terminal-actions' import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' -import { DictationController } from './components/dictation/DictationController' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' import { CrashReportDialog } from './components/crash-report/CrashReportDialog' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -117,9 +108,7 @@ import { import { selectFloatingVisibleTabCount } from './store/selectors' import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor' import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' -import type { OnboardingState } from '../../shared/types' -import { ContextualTourOverlay } from './components/contextual-tours/ContextualTourOverlay' -import { SetupGuideTelemetryObserver } from './components/setup-guide/SetupGuideTelemetryObserver' +import type { OnboardingState, UpdateStatus } from '../../shared/types' import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted @@ -235,9 +224,42 @@ const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceCo const WorkspaceCleanupDialog = lazy( () => import('./components/workspace-cleanup/WorkspaceCleanupDialog') ) +const Terminal = lazy(() => import('./components/Terminal')) +const StatusBar = lazy(() => + import('./components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar })) +) const SetupGuideModal = lazy(() => import('./components/setup-guide/SetupGuideModal')) const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal')) const FeatureTipsModal = lazy(() => import('./components/feature-tips/FeatureTipsModal')) +const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) +const DictationController = lazy(() => + import('./components/dictation/DictationController').then((module) => ({ + default: module.DictationController + })) +) +const SshPassphraseDialog = lazy(() => + import('./components/settings/SshPassphraseDialog').then((module) => ({ + default: module.SshPassphraseDialog + })) +) +const UpdateCard = lazy(() => + import('./components/UpdateCard').then((module) => ({ default: module.UpdateCard })) +) +const ContextualTourOverlay = lazy(() => + import('./components/contextual-tours/ContextualTourOverlay').then((module) => ({ + default: module.ContextualTourOverlay + })) +) +const SetupGuideTelemetryObserver = lazy(() => + import('./components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({ + default: module.SetupGuideTelemetryObserver + })) +) +const FloatingTerminalPanel = lazy(() => + import('./components/floating-terminal/FloatingTerminalPanel').then((module) => ({ + default: module.FloatingTerminalPanel + })) +) // Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless // the user opts into the experimental flag. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) @@ -276,6 +298,16 @@ function applyRemoteWorkspacePatchStatus( }) } +function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean { + if (status.state === 'idle') { + return false + } + if (status.state === 'checking' || status.state === 'not-available') { + return status.userInitiated === true + } + return true +} + function App(): React.JSX.Element { const clearUnreadDockBadge = useUnreadDockBadge() useRadixBodyPointerEventsRecovery() @@ -355,6 +387,8 @@ function App(): React.JSX.Element { const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const keybindings = useAppStore((s) => s.keybindings) + const updateStatus = useAppStore((s) => s.updateStatus) + const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle') const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle') const historyBackShortcutLabel = useShortcutLabel('worktree.history.back') @@ -367,6 +401,20 @@ function App(): React.JSX.Element { const showFloatingTerminalButton = floatingTerminalEnabled && (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) + const hasMountedTerminalWorkbenchRef = useRef(false) + if (activeWorktreeId !== null) { + hasMountedTerminalWorkbenchRef.current = true + } + // Why: skip the terminal bundle on the no-workspace landing path, but once a + // workspace has mounted, keep Terminal-owned hidden panes alive through sleep + // and shutdown transitions where activeWorktreeId can briefly become null. + const shouldMountTerminalWorkbench = + activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current + // Why: a closed empty floating workspace is not startup-critical. Once it owns + // tabs, keep it mounted while closed so hidden terminal/browser/editor panes + // retain their local state. + const shouldMountFloatingTerminalPanel = + floatingTerminalEnabled && (floatingTerminalOpen || floatingVisibleTabCount > 0) // Why: the floating workspace is a transient overlay; hotkey minimize should // return keyboard focus to the surface the user was working in before it. const floatingTerminalReturnFocusRef = useRef(null) @@ -465,11 +513,18 @@ function App(): React.JSX.Element { const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const shouldMountContextualTourOverlay = activeContextualTourId !== null + const shouldMountSetupGuideTelemetryObserver = persistedUIReady + const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) + const dictationState = useAppStore((s) => s.dictationState) + const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0) + const shouldMountDictationController = + settings?.voice?.enabled === true || dictationState !== 'idle' const primarySelectionMiddleClickPaste = resolvePrimarySelectionMiddleClickPaste( settings?.primarySelectionMiddleClickPaste ) @@ -516,7 +571,10 @@ function App(): React.JSX.Element { // cannot live under RightSidebar because App unmounts that subtree when the // sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind // until some unrelated view remount happens to refresh them. - useGitStatusPolling() + // Why: visible-window polling runs immediately on mount. Wait until the + // workspace session has hydrated so git status work cannot compete with the + // first window becoming usable. + useGitStatusPolling({ enabled: workspaceSessionReady }) // Why: the editor must hear external filesystem changes regardless of // which right-sidebar panel is visible (Explorer unmounts when the user // switches to Source Control or Checks). Wiring this at App level mirrors @@ -780,6 +838,10 @@ function App(): React.JSX.Element { } } + // Why: main overlaps daemon/hook startup with renderer hydration for + // first paint, but restored terminals still need those services ready + // before they mount and spawn/reconnect PTYs. + await window.api.app.awaitFirstWindowStartupServices() reconnectStarted = true await actions.reconnectPersistedTerminals(abortController.signal) syncZoomCSSVar() @@ -843,6 +905,7 @@ function App(): React.JSX.Element { // on-disk file we failed to load. if (!reconnectStarted) { try { + await window.api.app.awaitFirstWindowStartupServices() await actions.reconnectPersistedTerminals(abortController.signal) } catch (reconnectErr) { console.error( @@ -1700,7 +1763,7 @@ function App(): React.JSX.Element { > - + {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} @@ -1848,31 +1911,35 @@ function App(): React.JSX.Element { )}
-
- - - -
+ + + + + +
+ ) : null} - {floatingTerminalEnabled ? ( - - - + {shouldMountFloatingTerminalPanel ? ( + + + + + + ) : null} + {statusBarVisible ? ( + + } + > + + + + ) : null} - - - {/* Why: root overlays can render Radix s; keep them inside the shared provider so lazy surfaces mount safely from any entry point. */} @@ -2041,8 +2118,16 @@ function App(): React.JSX.Element { ) : null} - {persistedUIReady ? : null} - + {shouldMountSetupGuideTelemetryObserver ? ( + + + + ) : null} + {shouldMountContextualTourOverlay ? ( + + + + ) : null} {/* Why: mount PetOverlay only after persisted UI hydration, with both independent pet toggles allowing it; otherwise a hidden pet flashes while the store still has default visibility. */} @@ -2058,14 +2143,18 @@ function App(): React.JSX.Element { ) : null} - - - + {shouldMountUpdateCard ? ( + + + + + + ) : null} - - - - - - + + {activeModal === 'delete-worktree' ? ( + + + + ) : null} + + {hasSshCredentialRequest ? ( + + + + + + ) : null} ) : null} - - - + {shouldMountDictationController ? ( + + + + + + ) : null} { startupBlock.indexOf('await actions.fetchAllWorktrees()') ) }) + + it('waits for first-window startup services before terminal reconnect', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals') + const servicesIndex = source.indexOf('await window.api.app.awaitFirstWindowStartupServices()') + + expect(servicesIndex).toBeGreaterThanOrEqual(0) + expect(servicesIndex).toBeLessThan(reconnectIndex) + }) + + it('does not eagerly import the floating terminal panel on startup', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain( + "import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'" + ) + expect(source).toContain("import('./components/floating-terminal/FloatingTerminalPanel').then") + expect(source).not.toContain("from './components/floating-terminal/FloatingTerminalPanel'") + }) + + it('does not eagerly import idle optional overlay surfaces on startup', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain("import('./components/UpdateCard').then") + expect(source).toContain("import('./components/contextual-tours/ContextualTourOverlay').then") + expect(source).toContain("import('./components/setup-guide/SetupGuideTelemetryObserver').then") + expect(source).not.toContain("from './components/UpdateCard'") + expect(source).not.toContain("from './components/contextual-tours/ContextualTourOverlay'") + expect(source).not.toContain("from './components/setup-guide/SetupGuideTelemetryObserver'") + expect(source).toContain('const shouldMountSetupGuideTelemetryObserver = persistedUIReady') + expect(source).not.toContain( + "const shouldMountSetupGuideTelemetryObserver = persistedUIReady && activeModal === 'setup-guide'" + ) + }) + + it('keeps crash-report listeners eager while lazy-loading the dialog surface', () => { + const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const hostSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'), + 'utf8' + ) + + expect(appSource).toContain( + "import { CrashReportDialog } from './components/crash-report/CrashReportDialog'" + ) + expect(appSource).not.toContain("from './components/crash-report/CrashReportDialogSurface'") + expect(hostSource).toContain("import('./CrashReportDialogSurface').then") + expect(hostSource).toContain('window.api.crashReports.getLatestPending()') + expect(hostSource).toContain('window.api.ui.onOpenCrashReport') + expect(hostSource).toContain('REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT') + expect(hostSource).toContain('if (!open) {') + expect(hostSource).not.toContain('if (!open && !loading)') + }) + + it('clears stale crash-report state before opening the lazy manual report surface', () => { + const hostSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'), + 'utf8' + ) + const manualOpenStart = hostSource.indexOf('return window.api.ui.onOpenCrashReport(() => {') + const manualOpenEnd = hostSource.indexOf(' }, [loadCrashReport])', manualOpenStart) + const manualOpenBlock = hostSource.slice(manualOpenStart, manualOpenEnd) + + expect(manualOpenBlock.indexOf('setReport(null)')).toBeGreaterThanOrEqual(0) + expect(manualOpenBlock.indexOf('setReport(null)')).toBeLessThan( + manualOpenBlock.indexOf('setOpen(true)') + ) + expect(manualOpenBlock.indexOf('setReport(null)')).toBeLessThan( + manualOpenBlock.indexOf('loadCrashReport(false)') + ) + }) + + it('loads dictation only when voice is enabled or a session is active', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain("import('./components/dictation/DictationController').then") + expect(source).not.toContain("from './components/dictation/DictationController'") + expect(source).toContain("settings?.voice?.enabled === true || dictationState !== 'idle'") + expect(source).toContain('shouldMountDictationController ?') + }) + + it('loads the SSH passphrase dialog only when a credential request is queued', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain("import('./components/settings/SshPassphraseDialog').then") + expect(source).not.toContain("from './components/settings/SshPassphraseDialog'") + expect(source).toContain('s.sshCredentialQueue.length > 0') + expect(source).toContain('hasSshCredentialRequest ?') + }) + + it('defers background polling until the workspace session is ready', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain('useGitStatusPolling({ enabled: workspaceSessionReady })') + expect(source).toContain('') + }) + + it('does not load the terminal workbench on the no-workspace landing path', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal'))") + expect(source).not.toContain("from './components/Terminal'") + expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)') + expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true') + expect(source).toContain('activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current') + expect(source).toContain('shouldMountTerminalWorkbench ?') + }) + + it('does not eagerly import inactive sidebar dialog flows on startup', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/sidebar/index.tsx'), + 'utf8' + ) + + expect(source).toContain("React.lazy(() => import('./AddRepoDialog'))") + expect(source).toContain("React.lazy(() => import('./WorktreeMetaDialog'))") + expect(source).not.toContain("from './AddRepoDialog'") + expect(source).not.toContain("from './WorktreeMetaDialog'") + expect(source).toContain("activeModal === 'add-repo'") + expect(source).toContain('shouldMountAddRepoDialog ? : null') + expect(source).toContain('setTimeout(() =>') + expect(source).toContain("activeModal === 'edit-meta' ? : null") + }) + + it('does not eagerly import optional status-bar segments on startup', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'), + 'utf8' + ) + + expect(source).toContain("import('./ResourceUsageStatusSegment').then") + expect(source).toContain("import('./PortsStatusSegment').then") + expect(source).toContain("import('./SshStatusSegment').then") + expect(source).toContain("import('./PetStatusSegment').then") + expect(source).not.toContain("from './ResourceUsageStatusSegment'") + expect(source).not.toContain("from './PortsStatusSegment'") + expect(source).not.toContain("from './SshStatusSegment'") + expect(source).not.toContain("from './PetStatusSegment'") + }) + + it('does not eagerly import the status bar shell on startup', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + expect(source).toContain("import('./components/status-bar/StatusBar').then") + expect(source).not.toContain("from './components/status-bar/StatusBar'") + expect(source).toContain('statusBarVisible ? (') + expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border') + }) }) diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index 59501eecd..510ec81d7 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -99,6 +99,27 @@ describe('setUpdateStatus changelog caching', () => { expect(store.getState().updateChangelog).toBeNull() }) + it('preserves manual check intent through available for lazy-mounted update card', () => { + const store = createTestStore() + setState(store, { state: 'checking', userInitiated: true }) + expect(store.getState().updateUserInitiatedCycle).toBe(true) + + setState(store, { state: 'available', version: '1.2.0', changelog: null }) + expect(store.getState().updateUserInitiatedCycle).toBe(true) + + store.getState().dismissUpdate() + expect(store.getState().updateUserInitiatedCycle).toBe(false) + }) + + it('clears manual check intent when a background check starts', () => { + const store = createTestStore() + setState(store, { state: 'checking', userInitiated: true }) + expect(store.getState().updateUserInitiatedCycle).toBe(true) + + setState(store, { state: 'checking' }) + expect(store.getState().updateUserInitiatedCycle).toBe(false) + }) + it('overwrites previous rich changelog with null when new available has no changelog', () => { const store = createTestStore() setState(store, { state: 'available', version: '1.2.0', changelog: RICH_CHANGELOG }) @@ -294,6 +315,7 @@ type VisibilityInput = { dismissedVersion: string | null cachedVersion: string | null hasStartedDownload: boolean + updateUserInitiatedCycle?: boolean } type VisibilityResult = 'hidden' | 'visible' @@ -302,6 +324,7 @@ type VisibilityResult = 'hidden' | 'visible' function computeVisibility(input: VisibilityInput): VisibilityResult { const { status, dismissedVersion, cachedVersion, hasStartedDownload } = input const isUserInitiated = 'userInitiated' in status && status.userInitiated + const updateUserInitiatedCycle = input.updateUserInitiatedCycle ?? false const shouldShowDetailedErrorCard = status.state === 'error' && (hasStartedDownload || cachedVersion !== null) @@ -319,7 +342,7 @@ function computeVisibility(input: VisibilityInput): VisibilityResult { } const effectiveVersion = 'version' in status ? status.version : cachedVersion - if (effectiveVersion && dismissedVersion === effectiveVersion) { + if (effectiveVersion && dismissedVersion === effectiveVersion && !updateUserInitiatedCycle) { if (status.state !== 'downloading' && status.state !== 'error') { return 'hidden' } @@ -417,6 +440,18 @@ describe('UpdateCard visibility gates', () => { ).toBe('hidden') }) + it('shows dismissed available update when a lazy-mounted manual check cycle reaches available', () => { + expect( + computeVisibility({ + status: { state: 'available', version: '1.2.0', changelog: null }, + dismissedVersion: '1.2.0', + cachedVersion: '1.2.0', + hasStartedDownload: false, + updateUserInitiatedCycle: true + }) + ).toBe('visible') + }) + it('shows downloading even when version is dismissed (user clicked Update after dismiss)', () => { expect( computeVisibility({ diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 5c6f79e0d..fb354ceb3 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -66,8 +66,8 @@ function CompactCardContent({ return (
- {icon === "spinner" && } - {icon === "check" && } + {icon === 'spinner' && } + {icon === 'check' && } {icon === 'error' && }
@@ -87,7 +87,7 @@ function CompactCardContent({ size="icon" className="size-7 shrink-0" onClick={onClose} - aria-label={translate("auto.components.UpdateCard.a726967bd3", "Dismiss")} + aria-label={translate('auto.components.UpdateCard.a726967bd3', 'Dismiss')} > @@ -101,6 +101,7 @@ function CompactCardContent({ export function UpdateCard() { const status = useAppStore((s) => s.updateStatus) const storeChangelog = useAppStore((s) => s.updateChangelog) + const updateUserInitiatedCycle = useAppStore((s) => s.updateUserInitiatedCycle) const dismissedVersion = useAppStore((s) => s.dismissedUpdateVersion) const dismissUpdate = useAppStore((s) => s.dismissUpdate) const collapsed = useAppStore((s) => s.updateCardCollapsed) @@ -130,13 +131,6 @@ export function UpdateCard() { // Why: tracks whether the card is exiting so we can play the fade-out // animation before unmounting. const [exiting, setExiting] = useState(false) - // Why: when the user explicitly clicks "Check for Updates", the dismiss gate - // must be bypassed for the resulting 'available' card — otherwise the card - // flashes "Checking..." then vanishes because the same version was previously - // dismissed. This ref tracks whether the current check cycle was user-initiated - // so the dismiss gate can let the result through. - const userInitiatedCycleRef = useRef(false) - const changelog: ChangelogData | null = storeChangelog // Why: the 'error' variant of UpdateStatus does not carry a `version` field, @@ -245,18 +239,6 @@ export function UpdateCard() { const shouldShowDetailedErrorCard = status.state === 'error' && (hasStartedDownload.current || cachedVersion !== null) - // Why: track whether the current check cycle was user-initiated so the - // dismiss gate doesn't hide the result of an explicit "Check for Updates" - // click. Without this, clicking "Check for Updates" when a version was - // previously dismissed causes the "Checking..." toast to flash briefly - // then vanish — the 'available' card is suppressed by the dismiss gate - // even though the user explicitly asked to see the result. - if (status.state === 'checking' && isUserInitiated) { - userInitiatedCycleRef.current = true - } else if (status.state === 'idle' || (status.state === 'checking' && !isUserInitiated)) { - userInitiatedCycleRef.current = false - } - // Compact transient states: only show for user-initiated checks. if (status.state === 'checking' && !isUserInitiated) { return null @@ -293,11 +275,7 @@ export function UpdateCard() { // Why: bypass the gate when the current cycle was user-initiated — the user // explicitly asked to check, so they expect to see the result even if they // dismissed the same version earlier. - if ( - versionRef.current && - dismissedVersion === versionRef.current && - !userInitiatedCycleRef.current - ) { + if (versionRef.current && dismissedVersion === versionRef.current && !updateUserInitiatedCycle) { if (status.state !== 'downloading' && status.state !== 'error') { return null } @@ -327,10 +305,8 @@ export function UpdateCard() { // Why: the 'error' variant has no version field, so dismiss needs an // optional explicit version override for error/install-failure states. const handleClose = () => { - // Why: clear the user-initiated bypass so the dismiss gate re-engages - // immediately — otherwise the card would reappear on the next render - // because the bypass ref still overrides the persisted dismissal. - userInitiatedCycleRef.current = false + // Why: dismissUpdate clears the store-level manual-check bypass so the + // dismiss gate re-engages immediately after closing a requested result. if (status.state === 'error') { setErrorDismissed(true) if (cachedVersion) { @@ -367,12 +343,12 @@ export function UpdateCard() { ? isHttp2UpdateError ? { variant: 'http1Compatibility', - title: translate("auto.components.UpdateCard.1339b82cee", "HTTP/2 Download Blocked"), + title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), summary: 'Orca can retry through HTTP/1.1 compatibility mode.', message: compatibilitySetupError ?? status.message, releaseUrl: releaseUrlForVersion(cachedVersion), primaryAction: { - label: translate("auto.components.UpdateCard.933c6fdf5b", "Enable & Restart"), + label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), pendingLabel: 'Restarting...', isPending: compatibilityRelaunching, onClick: handleEnableHttp1Compatibility @@ -392,11 +368,11 @@ export function UpdateCard() { // of forcing the user into the manual fallback. primaryAction: cachedVersion ? { - label: translate("auto.components.UpdateCard.48565a32bc", "Retry Download"), + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), onClick: handleUpdate } : { - label: translate("auto.components.UpdateCard.6b0085010d", "Re-check"), + label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), onClick: () => { void window.api.updater.check({ includePrerelease: false }) } @@ -404,12 +380,12 @@ export function UpdateCard() { } : installError ? { - title: translate("auto.components.UpdateCard.4cf109845a", "Update Error"), + title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), summary: 'Could not restart to install the update.', message: installError, releaseUrl: releaseUrlForVersion(cachedVersion), primaryAction: { - label: translate("auto.components.UpdateCard.2c2d3e03ca", "Try Again"), + label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), onClick: handleInstallRetry } } @@ -494,11 +470,21 @@ export function UpdateCard() { // ── Compact transient states (user-initiated check feedback) ────── if (status.state === 'checking') { - return + return ( + + ) } if (status.state === 'not-available') { - return + return ( + + ) } // ── Error states ───────────────────────────────────────────────── @@ -523,7 +509,9 @@ export function UpdateCard() { if (hasStartedDownload.current) { return (
-

{translate("auto.components.UpdateCard.09a55c39b5", "Installing...")}

+

+ {translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')} +

) } @@ -608,14 +596,18 @@ export function UpdateCard() {

- {translate("auto.components.UpdateCard.b1d867f4fb", "Your terminal sessions won't be interrupted during the update.")}

+ {translate( + 'auto.components.UpdateCard.b1d867f4fb', + "Your terminal sessions won't be interrupted during the update." + )} +

@@ -670,13 +662,15 @@ function RichCardContent({ return (
-

{translate("auto.components.UpdateCard.f58b5c57a6", "New:")} {release.title}

+

+ {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} +

@@ -711,7 +705,9 @@ function RichCardContent({ className="text-xs text-muted-foreground/70 underline hover:text-foreground inline" onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)} > - +{releasesBehind - 1} {translate("auto.components.UpdateCard.ccd8b0a793", "more since your last update")} + +{releasesBehind - 1}{' '} + {translate('auto.components.UpdateCard.ccd8b0a793', 'more since your last update')} + )}

@@ -720,10 +716,12 @@ function RichCardContent({ className="text-xs text-muted-foreground underline hover:text-foreground self-start" onClick={() => void window.api.shell.openUrl(release.releaseNotesUrl)} > - {translate("auto.components.UpdateCard.aad383aecc", "Read the full release notes")} + {translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')} + + {translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')} +
) } @@ -744,28 +742,35 @@ function SimpleCardContent({ return (
-

{translate("auto.components.UpdateCard.9abc59f814", "Update Available")}

+

+ {translate('auto.components.UpdateCard.9abc59f814', 'Update Available')} +

-

{translate("auto.components.UpdateCard.93794ea932", "Orca v")}{version} {translate("auto.components.UpdateCard.c4890662e9", "is ready.")}

+

+ {translate('auto.components.UpdateCard.93794ea932', 'Orca v')} + {version} {translate('auto.components.UpdateCard.c4890662e9', 'is ready.')} +

- {translate("auto.components.UpdateCard.fdd4a364fa", "Sessions won't be interrupted.")}

+ {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} +

+ {translate('auto.components.UpdateCard.44324ef542', 'Release notes')} + + {translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')} +
) } @@ -809,16 +815,21 @@ function DownloadingContent({
{release ? ( -

{translate("auto.components.UpdateCard.f58b5c57a6", "New:")}{release.title}

+

+ {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} + {release.title} +

) : ( -

{translate("auto.components.UpdateCard.558842597d", "Downloading Update")}

+

+ {translate('auto.components.UpdateCard.558842597d', 'Downloading Update')} +

)} @@ -844,7 +855,11 @@ function DownloadingContent({ )}

- {release ? release.description : translate("auto.components.UpdateCard.93794ea932", "Orca v{{value0}} is downloading.", { value0: version })} + {release + ? release.description + : translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', { + value0: version + })}

-

{translate("auto.components.UpdateCard.6e45bfa2e0", "Downloading...")} {percent}%

+

+ {translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}% +

) @@ -907,7 +926,7 @@ function ErrorCardContent({ size="icon" className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2" onClick={onClose} - aria-label={translate("auto.components.UpdateCard.8acbdd3961", "Minimize to status bar")} + aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')} > @@ -916,12 +935,18 @@ function ErrorCardContent({ {isCompatibility ? (

- {translate("auto.components.UpdateCard.90559b14e3", "This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.")}

+ {translate( + 'auto.components.UpdateCard.90559b14e3', + 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' + )} +

) : null}
-

{translate("auto.components.UpdateCard.3553a8672f", "Last error")}

+

+ {translate('auto.components.UpdateCard.3553a8672f', 'Last error')} +

{message}

@@ -952,7 +977,8 @@ function ErrorCardContent({ onClick={() => void window.api.shell.openUrl(releaseUrl)} className={primaryAction ? 'flex-1' : 'w-full'} > - {translate("auto.components.UpdateCard.47126bcf57", "Download Manually")} + {translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')} +
) @@ -972,23 +998,32 @@ function ReadyToInstallContent({ return (
-

{translate("auto.components.UpdateCard.17412483da", "Ready to Install")}

+

+ {translate('auto.components.UpdateCard.17412483da', 'Ready to Install')} +

- {translate("auto.components.UpdateCard.93794ea932", "Orca v")}{version} {translate("auto.components.UpdateCard.02d4b8a6b9", "is downloaded. Restart when you're ready.")}

+ {translate('auto.components.UpdateCard.93794ea932', 'Orca v')} + {version}{' '} + {translate( + 'auto.components.UpdateCard.02d4b8a6b9', + "is downloaded. Restart when you're ready." + )} +

+ {translate('auto.components.UpdateCard.68b235d264', 'Restart to Update')} +
) } diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index 8c00d299f..de66ea797 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -1,116 +1,28 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Clipboard, Send } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' import { REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, takePendingReactErrorBoundaryReport } from '@/lib/react-error-boundary-reporting' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { useMountedRef } from '@/hooks/useMountedRef' -import { - formatCrashReportText, - isReactErrorBoundaryReport, - type CrashReportRecord -} from '../../../../shared/crash-reporting' -import type { GitHubViewer } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import type { CrashReportRecord } from '../../../../shared/crash-reporting' -function formatSummary(report: CrashReportRecord): string { - if (isReactErrorBoundaryReport(report)) { - const surface = typeof report.details.surface === 'string' ? report.details.surface : null - return surface ? `React render error in ${surface}` : 'React render error' - } - return `${report.processType} ${report.reason}${ - report.exitCode === null ? '' : ` (exit ${report.exitCode})` - }` -} +const CrashReportDialogSurface = lazy(() => + import('./CrashReportDialogSurface').then((module) => ({ + default: module.CrashReportDialogSurface + })) +) -function getDialogTitle(report: CrashReportRecord | null): string { - return report && isReactErrorBoundaryReport(report) - ? 'Orca hit a recoverable UI error' - : 'Orca closed unexpectedly' -} - -function getDialogDescription(report: CrashReportRecord | null): string { - return report && isReactErrorBoundaryReport(report) - ? 'Send a privacy-safe diagnostic report to help us understand the failed UI surface.' - : 'Send a privacy-safe diagnostic report to help us understand what happened.' -} - -function getNotesPlaceholder(report: CrashReportRecord | null): string { - return report && isReactErrorBoundaryReport(report) - ? 'Optional: what were you doing before this UI error?' - : 'Optional: what were you doing before Orca closed?' -} - -export function CrashReportDialog(): React.JSX.Element { +export function CrashReportDialog(): React.JSX.Element | null { const promptedThisLaunch = useRef(false) const mountedRef = useMountedRef() const [open, setOpen] = useState(false) const [report, setReport] = useState(null) - const [notes, setNotes] = useState('') const [loading, setLoading] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [viewer, setViewer] = useState(null) - // Why: account lookup can resolve after the dialog closes or reopens. - // Sequence the request so a stale viewer is never used for submission. - const viewerRequestIdRef = useRef(0) - const deferredNotes = useDeferredValue(notes) - const diagnosticText = useMemo( - // Why: formatting applies redaction and truncation over the full crash - // payload. Keep that preview update out of the textarea keystroke path. - () => (report ? formatCrashReportText(report, deferredNotes) : ''), - [deferredNotes, report] - ) - const clearViewer = useCallback((): void => { - viewerRequestIdRef.current += 1 - setViewer(null) - }, []) - - const loadViewerForOpenDialog = useCallback((): void => { - const requestId = ++viewerRequestIdRef.current - setViewer(null) - void window.api.gh - .viewer() - .then((nextViewer) => { - if (mountedRef.current && requestId === viewerRequestIdRef.current) { - setViewer(nextViewer) - } - }) - .catch((error) => { - if (mountedRef.current && requestId === viewerRequestIdRef.current) { - setViewer(null) - console.error('Failed to load GitHub viewer for crash report:', error) - } - }) - }, [mountedRef]) - - const openDialog = useCallback((): void => { + const openCrashReport = useCallback((nextReport: CrashReportRecord): void => { + setReport(nextReport) setOpen(true) - loadViewerForOpenDialog() - }, [loadViewerForOpenDialog]) - - const closeDialog = useCallback((): void => { - clearViewer() - setOpen(false) - }, [clearViewer]) - - const openCrashReport = useCallback( - (nextReport: CrashReportRecord): void => { - setReport(nextReport) - openDialog() - }, - [openDialog] - ) + }, []) const loadCrashReport = useCallback( async (promptIfPresent: boolean): Promise => { @@ -122,9 +34,9 @@ export function CrashReportDialog(): React.JSX.Element { let displayedReport = nextReport if (nextReport?.status === 'pending' && promptIfPresent) { try { - // Why: startup crash prompts are one-shot. The open dialog keeps the - // report data locally if the user chooses to send immediately, while - // Help > Report Crash can still reopen dismissed unsent reports. + // Why: startup crash prompts are one-shot. The lazy dialog keeps the + // report data locally if the user sends immediately, while Help > + // Report Crash can still reopen dismissed unsent reports. await window.api.crashReports.dismiss({ reportId: nextReport.id }) displayedReport = { ...nextReport, status: 'dismissed' as const } } catch (error) { @@ -136,7 +48,7 @@ export function CrashReportDialog(): React.JSX.Element { } setReport(displayedReport) if (nextReport && promptIfPresent) { - openDialog() + setOpen(true) } } catch (error) { console.error('Failed to load crash report:', error) @@ -146,7 +58,7 @@ export function CrashReportDialog(): React.JSX.Element { } } }, - [mountedRef, openDialog] + [mountedRef] ) useEffect(() => { @@ -159,13 +71,11 @@ export function CrashReportDialog(): React.JSX.Element { useEffect(() => { return window.api.ui.onOpenCrashReport(() => { - void loadCrashReport(false).then(() => { - if (mountedRef.current) { - openDialog() - } - }) + setReport(null) + setOpen(true) + void loadCrashReport(false) }) - }, [loadCrashReport, mountedRef, openDialog]) + }, [loadCrashReport]) useEffect(() => { const pendingReport = takePendingReactErrorBoundaryReport() @@ -189,141 +99,19 @@ export function CrashReportDialog(): React.JSX.Element { } }, [openCrashReport]) - const handleCopy = async (): Promise => { - const result = await window.api.crashReports.copyLatestDiagnostics( - report ? { reportId: report.id, notes } : {} - ) - if (!result.ok) { - toast.error(result.error) - return - } - toast.success(translate("auto.components.crash.report.CrashReportDialog.8b8473c544", "Crash report copied.")) - } - - const dismissReportIfNeeded = async (): Promise => { - if (report?.status === 'pending') { - await window.api.crashReports.dismiss({ reportId: report.id }) - if (mountedRef.current) { - setReport({ ...report, status: 'dismissed' }) - } - } - } - - const handleDismiss = async (): Promise => { - await dismissReportIfNeeded() - if (mountedRef.current) { - closeDialog() - } - } - - const handleSubmit = async (): Promise => { - if (!report) { - return - } - setSubmitting(true) - try { - const result = await window.api.crashReports.submit({ - reportId: report.id, - notes, - // Why: crash reporting must degrade to anonymous if gh is unavailable; - // identity lookup is best-effort and never blocks report creation. - submitAnonymously: !viewer, - githubLogin: viewer?.login ?? null, - githubEmail: null - }) - if (!result.ok) { - throw new Error(result.error) - } - if (!mountedRef.current) { - return - } - setReport(result.report) - setNotes('') - toast.success(translate("auto.components.crash.report.CrashReportDialog.8e24fe4f75", "Crash report sent.")) - closeDialog() - } catch (error) { - toast.error(translate("auto.components.crash.report.CrashReportDialog.56a3dfa283", "Failed to send crash report.")) - console.error('Failed to submit crash report:', error) - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } + if (!open) { + return null } return ( - { - if (submitting && !nextOpen) { - return - } - if (!nextOpen) { - clearViewer() - void dismissReportIfNeeded().finally(() => { - if (mountedRef.current) { - setOpen(false) - } - }) - return - } - openDialog() - }} - > - - - - - {getDialogTitle(report)} - - {getDialogDescription(report)} - - - {report ? ( -
-
-
{formatSummary(report)}
-
- {new Date(report.createdAt).toLocaleString()} · {report.platform} {report.arch} {translate("auto.components.crash.report.CrashReportDialog.835037edc9", "· Orca")}{report.appVersion} -
-
-