perf: speed up desktop startup (#4618)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
Trevin Chow 2026-06-09 12:15:04 -07:00 committed by GitHub
parent 28fe76f696
commit 5c31930e0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1899 additions and 631 deletions

View File

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

View File

@ -329,7 +329,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
}
export async function initDaemonPtyProvider(): Promise<void> {
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
const runtimeDir = getRuntimeDir()
const newSpawner = new DaemonSpawner({
@ -341,6 +341,11 @@ export async function initDaemonPtyProvider(): Promise<void> {
// 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<void> {
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

View File

@ -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<void> = Promise.resolve()
let localPtyStartupReady: Promise<void> = 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<void> {
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) => {

View File

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

View File

@ -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<void>((next) => {
resolve = next
})
return { promise, resolve }
}
describe('registerPtyHandlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => 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<string, string> }) => 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 () => [

View File

@ -1004,8 +1004,21 @@ export function registerPtyHandlers(
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store
store?: Store,
options?: {
awaitLocalPtyStartup?: () => Promise<void>
}
): void {
const getLocalPtyStartupPromise = (connectionId?: string | null): Promise<void> | 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()) {

View File

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

View File

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

View File

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

View File

@ -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<void>(() => {}),
startDaemonPtyProvider: (signal) => {
daemonSignal = signal
return new Promise<void>(() => {})
},
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()
}

View File

@ -1,33 +1,50 @@
type FirstWindowStartupServices = {
startDaemonPtyProvider: () => Promise<void>
startAgentHookServer: () => Promise<void>
startDaemonPtyProvider: (signal: AbortSignal) => Promise<void>
startAgentHookServer: (signal: AbortSignal) => Promise<void>
onDaemonError: (error: unknown) => void
onAgentHookServerError: (error: unknown) => void
}
type StartupService = {
ready: Promise<void>
reportTimeout: () => void
}
type FirstWindowStartupServicesResult = {
firstWindowReady: Promise<void>
localPtyReady: Promise<void>
}
export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000
async function startServiceWithTimeout(
function startService(
label: string,
start: () => Promise<void>,
start: (signal: AbortSignal) => Promise<void>,
onError: (error: unknown) => void
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
const startPromise = start()
await Promise.race([
startPromise,
new Promise<never>((_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<void> {
}: 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<typeof setTimeout> | null = null
let resolveTimedOut!: () => void
const timedOut = new Promise<void>((resolve) => {
resolveTimedOut = resolve
})
const firstWindowReady = Promise.race([
allServicesReady.finally(() => {
if (timeout) {
clearTimeout(timeout)
}
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
daemon.reportTimeout()
hooks.reportTimeout()
resolveTimedOut()
resolve()
}, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
})
])
const localPtyReady = Promise.race([allServicesReady, timedOut])
return { firstWindowReady, localPtyReady }
}

View File

@ -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<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((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()

View File

@ -51,6 +51,7 @@ export function attachMainWindowServices(
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>,
options?: {
awaitLocalPtyStartup?: () => Promise<void>
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)

View File

@ -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<void>
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise<void>
/** 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

View File

@ -405,6 +405,8 @@ const api = {
}
},
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
awaitFirstWindowStartupServices: (): Promise<void> =>
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

View File

@ -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<HTMLElement | null>(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 {
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<WorkspacePortScanner />
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
@ -1848,31 +1911,35 @@ function App(): React.JSX.Element {
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' ||
!activeWorktreeId ||
activeCreationLoaderVisible
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
{shouldMountTerminalWorkbench ? (
<div
className={
activeView !== 'terminal' ||
!activeWorktreeId ||
activeCreationLoaderVisible
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</div>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${activeView}`}
@ -1934,37 +2001,47 @@ function App(): React.JSX.Element {
) : null}
</div>
</RecoverableRenderErrorBoundary>
{floatingTerminalEnabled ? (
<RecoverableRenderErrorBoundary
boundaryId="overlay.floating-workspace"
surface="overlay"
resetKey={floatingTerminalOpen}
compact
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
description={translate(
'auto.App.7cbfbf622f',
'Retry the floating workspace or close and reopen it.'
)}
>
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
</RecoverableRenderErrorBoundary>
{shouldMountFloatingTerminalPanel ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.floating-workspace"
surface="overlay"
resetKey={floatingTerminalOpen}
compact
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
description={translate(
'auto.App.7cbfbf622f',
'Retry the floating workspace or close and reopen it.'
)}
>
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{statusBarVisible ? (
<Suspense
fallback={
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
}
>
<RecoverableRenderErrorBoundary
boundaryId="overlay.status-bar"
surface="overlay"
resetKey={activeView}
compact
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
description={translate(
'auto.App.8a023cea1f',
'Retry the status bar to remount its controls.'
)}
>
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.status-bar"
surface="overlay"
resetKey={activeView}
compact
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
description={translate(
'auto.App.8a023cea1f',
'Retry the status bar to remount its controls.'
)}
>
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
</RecoverableRenderErrorBoundary>
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
the shared provider so lazy surfaces mount safely from any entry point. */}
<Suspense fallback={null}>
@ -2041,8 +2118,16 @@ function App(): React.JSX.Element {
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{persistedUIReady ? <SetupGuideTelemetryObserver /> : null}
<ContextualTourOverlay />
{shouldMountSetupGuideTelemetryObserver ? (
<Suspense fallback={null}>
<SetupGuideTelemetryObserver />
</Suspense>
) : null}
{shouldMountContextualTourOverlay ? (
<Suspense fallback={null}>
<ContextualTourOverlay />
</Suspense>
) : 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 {
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.update-card"
surface="overlay"
resetKey={activeView}
compact
>
<UpdateCard />
</RecoverableRenderErrorBoundary>
{shouldMountUpdateCard ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.update-card"
surface="overlay"
resetKey={activeView}
compact
>
<UpdateCard />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.star-nag"
surface="overlay"
@ -2097,22 +2186,30 @@ function App(): React.JSX.Element {
>
<ZoomOverlay />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.ssh-passphrase"
surface="modal"
resetKey={activeModal}
compact
>
<SshPassphraseDialog />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.delete-worktree"
surface="modal"
resetKey={activeModal === 'delete-worktree'}
compact
>
<DeleteWorktreeDialog />
</RecoverableRenderErrorBoundary>
<Suspense fallback={null}>
{activeModal === 'delete-worktree' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.delete-worktree"
surface="modal"
resetKey
compact
>
<DeleteWorktreeDialog />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{hasSshCredentialRequest ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="modal.ssh-passphrase"
surface="modal"
resetKey={activeModal}
compact
>
<SshPassphraseDialog />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="modal.markdown-template-picker"
surface="modal"
@ -2155,14 +2252,18 @@ function App(): React.JSX.Element {
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.dictation"
surface="overlay"
resetKey={activeView}
compact
>
<DictationController />
</RecoverableRenderErrorBoundary>
{shouldMountDictationController ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.dictation"
surface="overlay"
resetKey={activeView}
compact
>
<DictationController />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.recent-tab-switcher"
surface="overlay"

View File

@ -17,4 +17,152 @@ describe('renderer startup runtime routing', () => {
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('<WorkspacePortScanner enabled={workspaceSessionReady} />')
})
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 ? <AddRepoDialog /> : null')
expect(source).toContain('setTimeout(() =>')
expect(source).toContain("activeModal === 'edit-meta' ? <WorktreeMetaDialog /> : 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')
})
})

View File

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

View File

@ -66,8 +66,8 @@ function CompactCardContent({
return (
<div className="flex items-center gap-3 p-3">
<div className="shrink-0 text-muted-foreground">
{icon === "spinner" && <Loader2 className="size-4 animate-spin" />}
{icon === "check" && <Check className="size-4" />}
{icon === 'spinner' && <Loader2 className="size-4 animate-spin" />}
{icon === 'check' && <Check className="size-4" />}
{icon === 'error' && <AlertCircle className="size-4" />}
</div>
<div className="flex-1 min-w-0">
@ -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')}
>
<X className="size-3.5" />
</Button>
@ -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 <CompactCardContent icon="spinner" text={translate("auto.components.UpdateCard.ba5ffc949c", "Checking for updates...")} />
return (
<CompactCardContent
icon="spinner"
text={translate('auto.components.UpdateCard.ba5ffc949c', 'Checking for updates...')}
/>
)
}
if (status.state === 'not-available') {
return <CompactCardContent icon="check" text={translate("auto.components.UpdateCard.ea2a41adbe", "You're on the latest version.")} />
return (
<CompactCardContent
icon="check"
text={translate('auto.components.UpdateCard.ea2a41adbe', "You're on the latest version.")}
/>
)
}
// ── Error states ─────────────────────────────────────────────────
@ -523,7 +509,9 @@ export function UpdateCard() {
if (hasStartedDownload.current) {
return (
<div className="p-4">
<p className="text-sm">{translate("auto.components.UpdateCard.09a55c39b5", "Installing...")}</p>
<p className="text-sm">
{translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')}
</p>
</div>
)
}
@ -608,14 +596,18 @@ export function UpdateCard() {
<div className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{translate("auto.components.UpdateCard.b1d867f4fb", "Your terminal sessions won't be interrupted during the update.")}</p>
{translate(
'auto.components.UpdateCard.b1d867f4fb',
"Your terminal sessions won't be interrupted during the update."
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={markReassuranceSeen}
aria-label={translate("auto.components.UpdateCard.7274ef6e59", "Dismiss tip")}
aria-label={translate('auto.components.UpdateCard.7274ef6e59', 'Dismiss tip')}
>
<X className="size-3.5" />
</Button>
@ -670,13 +662,15 @@ function RichCardContent({
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{translate("auto.components.UpdateCard.f58b5c57a6", "New:")} {release.title}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate("auto.components.UpdateCard.318d3b4bc7", "Dismiss update")}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
@ -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")}</button>
+{releasesBehind - 1}{' '}
{translate('auto.components.UpdateCard.ccd8b0a793', 'more since your last update')}
</button>
</>
)}
</p>
@ -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")}</button>
{translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')}
</button>
<Button variant="default" size="sm" onClick={onUpdate} className="w-full cursor-pointer">
{translate("auto.components.UpdateCard.ec8fe71cfc", "Update")}</Button>
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
@ -744,28 +742,35 @@ function SimpleCardContent({
return (
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{translate("auto.components.UpdateCard.9abc59f814", "Update Available")}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.9abc59f814', 'Update Available')}
</h3>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onClose}
aria-label={translate("auto.components.UpdateCard.318d3b4bc7", "Dismiss update")}
aria-label={translate('auto.components.UpdateCard.318d3b4bc7', 'Dismiss update')}
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{translate("auto.components.UpdateCard.93794ea932", "Orca v")}{version} {translate("auto.components.UpdateCard.c4890662e9", "is ready.")}</p>
<p className="text-sm text-muted-foreground">
{translate('auto.components.UpdateCard.93794ea932', 'Orca v')}
{version} {translate('auto.components.UpdateCard.c4890662e9', 'is ready.')}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate("auto.components.UpdateCard.fdd4a364fa", "Sessions won't be interrupted.")}</p>
{translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}
</p>
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
>
{translate("auto.components.UpdateCard.44324ef542", "Release notes")}</button>
{translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
<Button
variant="default"
@ -773,7 +778,8 @@ function SimpleCardContent({
onClick={onUpdate}
className="mt-0.5 w-full cursor-pointer"
>
{translate("auto.components.UpdateCard.ec8fe71cfc", "Update")}</Button>
{translate('auto.components.UpdateCard.ec8fe71cfc', 'Update')}
</Button>
</div>
)
}
@ -809,16 +815,21 @@ function DownloadingContent({
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
{release ? (
<h3 className="text-sm font-semibold">{translate("auto.components.UpdateCard.f58b5c57a6", "New:")}{release.title}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.f58b5c57a6', 'New:')}
{release.title}
</h3>
) : (
<h3 className="text-sm font-semibold">{translate("auto.components.UpdateCard.558842597d", "Downloading Update")}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.558842597d', 'Downloading Update')}
</h3>
)}
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 min-w-[44px] min-h-[44px] -m-2"
onClick={onCollapse}
aria-label={translate("auto.components.UpdateCard.8acbdd3961", "Minimize to status bar")}
aria-label={translate('auto.components.UpdateCard.8acbdd3961', 'Minimize to status bar')}
>
<Minus className="size-3.5" />
</Button>
@ -844,7 +855,11 @@ function DownloadingContent({
)}
<p className="text-sm text-muted-foreground">
{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
})}
</p>
<button
@ -855,12 +870,16 @@ function DownloadingContent({
)
}
>
{release ? translate("auto.components.UpdateCard.aad383aecc", "Read the full release notes") : translate("auto.components.UpdateCard.44324ef542", "Release notes")}
{release
? translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')
: translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
<div className="flex flex-col gap-2 mt-1">
<Progress value={percent} className="h-1.5" />
<p className="text-xs text-muted-foreground">{translate("auto.components.UpdateCard.6e45bfa2e0", "Downloading...")} {percent}%</p>
<p className="text-xs text-muted-foreground">
{translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}%
</p>
</div>
</div>
)
@ -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')}
>
<Minus className="size-3.5" />
</Button>
@ -916,12 +935,18 @@ function ErrorCardContent({
{isCompatibility ? (
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<p className="text-xs leading-relaxed text-muted-foreground">
{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.")}</p>
{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.'
)}
</p>
</div>
) : null}
<div className="rounded-md bg-muted/40 px-3 py-2">
<p className="mb-1 text-[11px] font-medium uppercase text-muted-foreground">{translate("auto.components.UpdateCard.3553a8672f", "Last error")}</p>
<p className="mb-1 text-[11px] font-medium uppercase text-muted-foreground">
{translate('auto.components.UpdateCard.3553a8672f', 'Last error')}
</p>
<p className="scrollbar-sleek max-h-20 overflow-auto break-words font-mono text-xs leading-relaxed text-muted-foreground">
{message}
</p>
@ -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")}</Button>
{translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')}
</Button>
</div>
</div>
)
@ -972,23 +998,32 @@ function ReadyToInstallContent({
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{translate("auto.components.UpdateCard.17412483da", "Ready to Install")}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.UpdateCard.17412483da', 'Ready to Install')}
</h3>
<Button
variant="ghost"
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')}
>
<Minus className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{translate("auto.components.UpdateCard.93794ea932", "Orca v")}{version} {translate("auto.components.UpdateCard.02d4b8a6b9", "is downloaded. Restart when you're ready.")}</p>
{translate('auto.components.UpdateCard.93794ea932', 'Orca v')}
{version}{' '}
{translate(
'auto.components.UpdateCard.02d4b8a6b9',
"is downloaded. Restart when you're ready."
)}
</p>
<Button variant="default" size="sm" onClick={onRestart} className="w-full">
{translate("auto.components.UpdateCard.68b235d264", "Restart to Update")}</Button>
{translate('auto.components.UpdateCard.68b235d264', 'Restart to Update')}
</Button>
</div>
)
}

View File

@ -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<CrashReportRecord | null>(null)
const [notes, setNotes] = useState('')
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [viewer, setViewer] = useState<GitHubViewer | null>(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<void> => {
@ -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<void> => {
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<void> => {
if (report?.status === 'pending') {
await window.api.crashReports.dismiss({ reportId: report.id })
if (mountedRef.current) {
setReport({ ...report, status: 'dismissed' })
}
}
}
const handleDismiss = async (): Promise<void> => {
await dismissReportIfNeeded()
if (mountedRef.current) {
closeDialog()
}
}
const handleSubmit = async (): Promise<void> => {
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (submitting && !nextOpen) {
return
}
if (!nextOpen) {
clearViewer()
void dismissReportIfNeeded().finally(() => {
if (mountedRef.current) {
setOpen(false)
}
})
return
}
openDialog()
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-4 text-destructive" />
{getDialogTitle(report)}
</DialogTitle>
<DialogDescription className="text-xs">{getDialogDescription(report)}</DialogDescription>
</DialogHeader>
{report ? (
<div className="space-y-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs">
<div className="font-medium text-foreground">{formatSummary(report)}</div>
<div className="mt-1 text-muted-foreground">
{new Date(report.createdAt).toLocaleString()} · {report.platform} {report.arch} {translate("auto.components.crash.report.CrashReportDialog.835037edc9", "· Orca")}{report.appVersion}
</div>
</div>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
rows={4}
placeholder={getNotesPlaceholder(report)}
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.crash.report.CrashReportDialog.6d3ebe216a", "Diagnostic text")}</div>
<pre className="max-h-44 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 font-mono text-[11px] leading-5 text-muted-foreground scrollbar-sleek">
{diagnosticText}
</pre>
</div>
</div>
) : (
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
{loading ? translate("auto.components.crash.report.CrashReportDialog.765591798d", "Checking for crash reports...") : translate("auto.components.crash.report.CrashReportDialog.b175e90213", "No crash report is available.")}
</div>
)}
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={!report}>
<Clipboard className="size-3.5" />
{translate("auto.components.crash.report.CrashReportDialog.50b00dc327", "Copy Details")}</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDismiss}
disabled={submitting}
>
{translate("auto.components.crash.report.CrashReportDialog.88fea8e84e", "Don't Send")}</Button>
<Button type="button" size="sm" onClick={handleSubmit} disabled={!report || submitting}>
<Send className="size-3.5" />
{translate("auto.components.crash.report.CrashReportDialog.b4951cd27c", "Send Report")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Suspense fallback={null}>
<CrashReportDialogSurface
open={open}
report={report}
loading={loading}
onOpenChange={setOpen}
onReportChange={setReport}
/>
</Suspense>
)
}

View File

@ -0,0 +1,275 @@
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 {
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'
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})`
}`
}
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?'
}
type CrashReportDialogSurfaceProps = {
open: boolean
report: CrashReportRecord | null
loading: boolean
onOpenChange: (open: boolean) => void
onReportChange: (report: CrashReportRecord | null) => void
}
export function CrashReportDialogSurface({
open,
report,
loading,
onOpenChange,
onReportChange
}: CrashReportDialogSurfaceProps): React.JSX.Element {
const mountedRef = useMountedRef()
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const [viewer, setViewer] = useState<GitHubViewer | null>(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])
useEffect(() => {
if (!open) {
clearViewer()
return
}
loadViewerForOpenDialog()
}, [clearViewer, loadViewerForOpenDialog, open])
const handleCopy = async (): Promise<void> => {
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<void> => {
if (report?.status === 'pending') {
await window.api.crashReports.dismiss({ reportId: report.id })
if (mountedRef.current) {
onReportChange({ ...report, status: 'dismissed' })
}
}
}
const handleDismiss = async (): Promise<void> => {
await dismissReportIfNeeded()
if (mountedRef.current) {
onOpenChange(false)
}
}
const handleSubmit = async (): Promise<void> => {
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
}
onReportChange(result.report)
setNotes('')
toast.success(
translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.')
)
onOpenChange(false)
} 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)
}
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (submitting && !nextOpen) {
return
}
if (!nextOpen) {
clearViewer()
void dismissReportIfNeeded().finally(() => {
if (mountedRef.current) {
onOpenChange(false)
}
})
return
}
onOpenChange(true)
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-4 text-destructive" />
{getDialogTitle(report)}
</DialogTitle>
<DialogDescription className="text-xs">{getDialogDescription(report)}</DialogDescription>
</DialogHeader>
{report ? (
<div className="space-y-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs">
<div className="font-medium text-foreground">{formatSummary(report)}</div>
<div className="mt-1 text-muted-foreground">
{new Date(report.createdAt).toLocaleString()} · {report.platform} {report.arch} ·
{translate('auto.components.crash.report.CrashReportDialog.835037edc9', 'Orca')}{' '}
{report.appVersion}
</div>
</div>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
rows={4}
placeholder={getNotesPlaceholder(report)}
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-muted-foreground">
{translate(
'auto.components.crash.report.CrashReportDialog.6d3ebe216a',
'Diagnostic text'
)}
</div>
<pre className="max-h-44 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 font-mono text-[11px] leading-5 text-muted-foreground scrollbar-sleek">
{diagnosticText}
</pre>
</div>
</div>
) : (
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
{loading
? translate(
'auto.components.crash.report.CrashReportDialog.765591798d',
'Checking for crash reports...'
)
: translate(
'auto.components.crash.report.CrashReportDialog.b175e90213',
'No crash report is available.'
)}
</div>
)}
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={!report}>
<Clipboard className="size-3.5" />
{translate('auto.components.crash.report.CrashReportDialog.50b00dc327', 'Copy Details')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDismiss}
disabled={submitting}
>
{translate('auto.components.crash.report.CrashReportDialog.88fea8e84e', "Don't Send")}
</Button>
<Button type="button" size="sm" onClick={handleSubmit} disabled={!report || submitting}>
<Send className="size-3.5" />
{translate('auto.components.crash.report.CrashReportDialog.b4951cd27c', 'Send Report')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -21,7 +21,7 @@ function makeUnavailableScan(reason: string): WorkspacePortScanResult {
}
}
export function WorkspacePortScanner(): null {
export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): null {
const settings = useAppStore((s) => s.settings)
const hasWorktrees = useAppStore(getHasAnyWorktreesFromState)
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
@ -69,6 +69,9 @@ export function WorkspacePortScanner(): null {
}, [hasWorktrees, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing])
useEffect(() => {
if (!enabled) {
return
}
generationRef.current += 1
setWorkspacePortScan(null)
@ -85,9 +88,12 @@ export function WorkspacePortScanner(): null {
inFlightRef.current = null
stopVisibleInterval()
}
}, [refresh, setWorkspacePortScan])
}, [enabled, refresh, setWorkspacePortScan])
useEffect(() => {
if (!enabled) {
return
}
if (runtimeTarget.kind !== 'local') {
return
}
@ -130,7 +136,7 @@ export function WorkspacePortScanner(): null {
clearRetryTimer()
unsubscribe()
}
}, [refresh, runtimeTarget])
}, [enabled, refresh, runtimeTarget])
return null
}

View File

@ -15,11 +15,6 @@ import {
ContextMenuRadioGroup,
ContextMenuRadioItem
} from '@/components/ui/context-menu'
import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
import PortsPanel from './PortsPanel'
import { getTopActivityBarLayout } from './activity-bar-overflow'
import {
ActivityBarButton,
@ -40,6 +35,7 @@ import {
computeMaxRightSidebarPanelWidth
} from './right-sidebar-width'
import { translate } from '@/i18n/i18n'
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
const ACTIVITY_BAR_SIDE_WIDTH = 40
@ -77,33 +73,33 @@ function RightSidebarInner(): React.JSX.Element {
{
id: 'explorer',
icon: Files,
title: translate("auto.components.right.sidebar.index.8bc2bbc3a0", "Explorer"),
title: translate('auto.components.right.sidebar.index.8bc2bbc3a0', 'Explorer'),
shortcut: explorerShortcut === 'Unassigned' ? '' : explorerShortcut
},
{
id: 'search',
icon: Search,
title: translate("auto.components.right.sidebar.index.06219e4cb1", "Search"),
title: translate('auto.components.right.sidebar.index.06219e4cb1', 'Search'),
shortcut: searchShortcut === 'Unassigned' ? '' : searchShortcut
},
{
id: 'source-control',
icon: GitBranch,
title: translate("auto.components.right.sidebar.index.0314901467", "Source Control"),
title: translate('auto.components.right.sidebar.index.0314901467', 'Source Control'),
shortcut: sourceControlShortcut === 'Unassigned' ? '' : sourceControlShortcut,
gitOnly: true
},
{
id: 'checks',
icon: ListChecks,
title: translate("auto.components.right.sidebar.index.83a10e3c44", "Checks"),
title: translate('auto.components.right.sidebar.index.83a10e3c44', 'Checks'),
shortcut: checksShortcut === 'Unassigned' ? '' : checksShortcut,
gitOnly: true
},
{
id: 'ports',
icon: Plug,
title: translate("auto.components.right.sidebar.index.441733b630", "Ports"),
title: translate('auto.components.right.sidebar.index.441733b630', 'Ports'),
shortcut: portsShortcut === 'Unassigned' ? '' : portsShortcut,
sshOnly: true
}
@ -154,18 +150,7 @@ function RightSidebarInner(): React.JSX.Element {
property) rather than in a bottom-docked dashboard panel that
competed with file Explorer/Search for vertical space. The right
sidebar is back to tab-only content. */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{effectiveTab === "explorer" && <FileExplorer />}
{effectiveTab === "search" && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === "checks" && <ChecksPanel />}
{/* Why: SSH port forwarding still depends on the raw ports.detect data,
which the workspace-scoped status bar popover intentionally does not
expose. Keep this panel reachable only for SSH worktrees. */}
{effectiveTab === "ports" && (
<PortsPanel isVisible={rightSidebarOpen && effectiveTab === 'ports'} />
)}
</div>
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={rightSidebarOpen} />
</div>
) : null
@ -192,13 +177,20 @@ function RightSidebarInner(): React.JSX.Element {
type="button"
className="sidebar-toggle mr-1"
onClick={toggleRightSidebar}
aria-label={translate("auto.components.right.sidebar.index.e8e2e4ce74", "Toggle right sidebar")}
aria-label={translate(
'auto.components.right.sidebar.index.e8e2e4ce74',
'Toggle right sidebar'
)}
>
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.right.sidebar.index.9fffaf17c1", "Toggle right sidebar ({{value0}})", { value0: rightSidebarShortcut })}
{translate(
'auto.components.right.sidebar.index.9fffaf17c1',
'Toggle right sidebar ({{value0}})',
{ value0: rightSidebarShortcut }
)}
</TooltipContent>
</Tooltip>
) : null
@ -223,7 +215,7 @@ function RightSidebarInner(): React.JSX.Element {
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{activityBarPosition === "top" ? (
{activityBarPosition === 'top' ? (
/* ── Top activity bar: horizontal icon row ── */
<ContextMenu>
<div className="flex h-[36px] min-h-[36px] items-center border-b border-border right-sidebar-header-inset right-sidebar-header-drag overflow-hidden">
@ -355,7 +347,7 @@ function RightSidebarInner(): React.JSX.Element {
</div>
{/* Side Activity Bar (icon strip on right edge) — only for 'side' position */}
{activityBarPosition === "side" && (
{activityBarPosition === 'side' && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border side-activity-bar-windows-inset">
@ -434,13 +426,19 @@ function ActivityBarPositionMenu({
}): React.JSX.Element {
return (
<ContextMenuContent>
<ContextMenuLabel>{translate("auto.components.right.sidebar.index.864111caa2", "Activity Bar Position")}</ContextMenuLabel>
<ContextMenuLabel>
{translate('auto.components.right.sidebar.index.864111caa2', 'Activity Bar Position')}
</ContextMenuLabel>
<ContextMenuRadioGroup
value={currentPosition}
onValueChange={(v) => onChangePosition(v as ActivityBarPosition)}
>
<ContextMenuRadioItem value="top">{translate("auto.components.right.sidebar.index.7b415c39e9", "Top")}</ContextMenuRadioItem>
<ContextMenuRadioItem value="side">{translate("auto.components.right.sidebar.index.70893f017b", "Side")}</ContextMenuRadioItem>
<ContextMenuRadioItem value="top">
{translate('auto.components.right.sidebar.index.7b415c39e9', 'Top')}
</ContextMenuRadioItem>
<ContextMenuRadioItem value="side">
{translate('auto.components.right.sidebar.index.70893f017b', 'Side')}
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
)

View File

@ -0,0 +1,35 @@
import { lazy, Suspense } from 'react'
import type { RightSidebarTab } from '@/store/slices/editor'
const FileExplorer = lazy(() => import('./FileExplorer'))
const SearchPanel = lazy(() => import('./Search'))
const SourceControl = lazy(() => import('./SourceControl'))
const ChecksPanel = lazy(() => import('./ChecksPanel'))
const PortsPanel = lazy(() => import('./PortsPanel'))
type RightSidebarPanelContentProps = {
effectiveTab: RightSidebarTab
rightSidebarOpen: boolean
}
export function RightSidebarPanelContent({
effectiveTab,
rightSidebarOpen
}: RightSidebarPanelContentProps): React.JSX.Element {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Suspense fallback={null}>
{effectiveTab === 'explorer' && <FileExplorer />}
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{/* Why: SSH port forwarding still depends on the raw ports.detect data,
which the workspace-scoped status bar popover intentionally does not
expose. Keep this panel reachable only for SSH worktrees. */}
{effectiveTab === 'ports' && (
<PortsPanel isVisible={rightSidebarOpen && effectiveTab === 'ports'} />
)}
</Suspense>
</div>
)
}

View File

@ -16,10 +16,20 @@ type PollState = {
sshConnectionStates: Map<string, { status: string }>
}
type GitStatusPollingHook = () => void
type GitStatusPollingHook = (options?: { enabled?: boolean }) => void
function GitStatusPollingHarness({ runPolling }: { runPolling: GitStatusPollingHook }): null {
runPolling()
function GitStatusPollingHarness({
enabled,
runPolling
}: {
enabled?: boolean
runPolling: GitStatusPollingHook
}): null {
if (enabled === undefined) {
runPolling()
} else {
runPolling({ enabled })
}
return null
}
@ -29,6 +39,7 @@ async function usePollingOnce(
connectionId?: string | null
pushTarget?: GitPushTarget
sshStatus?: string
enabled?: boolean
expectStatusCall?: boolean
} = {}
): Promise<{ state: PollState; gitStatus: ReturnType<typeof vi.fn> }> {
@ -104,7 +115,7 @@ async function usePollingOnce(
vi.stubGlobal('clearInterval', vi.fn())
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
GitStatusPollingHarness({ runPolling })
GitStatusPollingHarness({ enabled: options.enabled, runPolling })
await (options.expectStatusCall !== false
? vi.waitFor(() => {
expect(state.setGitStatus).toHaveBeenCalled()
@ -190,6 +201,22 @@ describe('useGitStatusPolling', () => {
expect(state.setGitStatus).not.toHaveBeenCalled()
})
it('does not install the visible git status poll while disabled', async () => {
const { state, gitStatus } = await usePollingOnce(
{
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
branch: 'refs/heads/main'
},
{ enabled: false, expectStatusCall: false }
)
expect(gitStatus).not.toHaveBeenCalled()
expect(state.setGitStatus).not.toHaveBeenCalled()
expect(globalThis.setInterval).not.toHaveBeenCalled()
})
it('does not overlap slow visible git status polls and runs one trailing refresh', async () => {
vi.resetModules()
let intervalCallback: (() => void) | null = null

View File

@ -12,7 +12,8 @@ import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access'
const POLL_INTERVAL_MS = 3000
export function useGitStatusPolling(): void {
export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
const enabled = options.enabled ?? true
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorktree = useWorktreeById(activeWorktreeId)
const allWorktrees = useAllWorktrees()
@ -66,6 +67,9 @@ export function useGitStatusPolling(): void {
}, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap])
const runFetchStatus = useCallback(async () => {
if (!enabled) {
return
}
if (!activeWorktreeId || !worktreePath) {
return
}
@ -107,6 +111,7 @@ export function useGitStatusPolling(): void {
activeConnectionId,
activePushTarget,
activeWorktreeId,
enabled,
fetchUpstreamStatus,
isConnectionReady,
openFiles,
@ -138,15 +143,21 @@ export function useGitStatusPolling(): void {
fetchStatusRef.current = fetchStatus
useEffect(() => {
if (!enabled) {
return
}
// Why: this root-level poll should pause while hidden, but visible
// unfocused windows still need fresh status for second-display workflows.
return installWindowVisibilityInterval({ run: fetchStatus, intervalMs: POLL_INTERVAL_MS })
}, [fetchStatus])
}, [enabled, fetchStatus])
// Why: poll conflict operation for non-active worktrees that have a stale
// non-unknown operation. This is a lightweight fs-only check (no git status)
// so it won't cause performance issues even with many worktrees.
useEffect(() => {
if (!enabled) {
return
}
if (staleConflictWorktrees.length === 0) {
return
}
@ -187,5 +198,5 @@ export function useGitStatusPolling(): void {
pollRunner.dispose()
stopVisiblePoll()
}
}, [staleConflictWorktrees, setConflictOperation, isConnectionReady])
}, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady])
}

View File

@ -7,19 +7,20 @@ import SidebarNav from './SidebarNav'
import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorktreeMetaDialog from './WorktreeMetaDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
import RemoveFolderDialog from './RemoveFolderDialog'
import AddRepoDialog from './AddRepoDialog'
import AddProjectFromFolderDialog from './AddProjectFromFolderDialog'
import ProjectAddedDialog from './ProjectAddedDialog'
import WorktreeVisibilityDialog from './WorktreeVisibilityDialog'
import OrcaYamlTrustDialog from './OrcaYamlTrustDialog'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import { cn } from '@/lib/utils'
import { FolderPlus, Loader2 } from 'lucide-react'
import { useSidebarProjectDrop } from './useSidebarProjectDrop'
const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog'))
const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog'))
const RemoveFolderDialog = React.lazy(() => import('./RemoveFolderDialog'))
const AddRepoDialog = React.lazy(() => import('./AddRepoDialog'))
const AddProjectFromFolderDialog = React.lazy(() => import('./AddProjectFromFolderDialog'))
const ProjectAddedDialog = React.lazy(() => import('./ProjectAddedDialog'))
const WorktreeVisibilityDialog = React.lazy(() => import('./WorktreeVisibilityDialog'))
const OrcaYamlTrustDialog = React.lazy(() => import('./OrcaYamlTrustDialog'))
const MIN_WIDTH = 220
const MAX_WIDTH = 500
// Why: match the right sidebar's 4px resize target; a 1px seam is too hard to acquire.
@ -40,7 +41,10 @@ function Sidebar({
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
const repos = useAppStore((s) => s.repos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const activeModal = useAppStore((s) => s.activeModal)
const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop()
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false)
const unmountAddRepoDialogTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const setLiveSidebarWidth = React.useCallback((width: number) => {
document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`)
@ -54,6 +58,31 @@ function Sidebar({
}
}, [repoCount, fetchAllWorktrees])
useEffect(() => {
if (activeModal === 'add-repo') {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
setShouldMountAddRepoDialog(true)
return
}
if (shouldMountAddRepoDialog && !unmountAddRepoDialogTimerRef.current) {
// Why: AddRepoDialog's close effect aborts in-flight clone/nested work.
// Keep one closed render, then remove hidden SSH/remote subscriptions.
unmountAddRepoDialogTimerRef.current = setTimeout(() => {
setShouldMountAddRepoDialog(false)
unmountAddRepoDialogTimerRef.current = null
}, 0)
}
return () => {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
}
}, [activeModal, shouldMountAddRepoDialog])
const { containerRef, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
@ -99,7 +128,7 @@ function Sidebar({
: 'border-worktree-sidebar-ring/70'
)}
>
{affordance.tone === "busy" ? (
{affordance.tone === 'busy' ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<FolderPlus className="size-5 text-muted-foreground" />
@ -119,15 +148,18 @@ function Sidebar({
)}
</div>
{/* Dialog (rendered outside sidebar to avoid clipping) */}
<WorktreeMetaDialog />
<NonGitFolderDialog />
<RemoveFolderDialog />
<AddRepoDialog />
<AddProjectFromFolderDialog />
<ProjectAddedDialog />
<WorktreeVisibilityDialog />
<OrcaYamlTrustDialog />
{/* Dialogs render outside sidebar to avoid clipping. Lazy-load them only
for the modal that needs their flow-specific hooks and UI. */}
<React.Suspense fallback={null}>
{activeModal === 'edit-meta' ? <WorktreeMetaDialog /> : null}
{activeModal === 'confirm-non-git-folder' ? <NonGitFolderDialog /> : null}
{activeModal === 'confirm-remove-folder' ? <RemoveFolderDialog /> : null}
{shouldMountAddRepoDialog ? <AddRepoDialog /> : null}
{activeModal === 'confirm-add-project-from-folder' ? <AddProjectFromFolderDialog /> : null}
{activeModal === 'project-added' ? <ProjectAddedDialog /> : null}
{activeModal === 'worktree-visibility' ? <WorktreeVisibilityDialog /> : null}
{activeModal === 'confirm-orca-yaml-hooks' ? <OrcaYamlTrustDialog /> : null}
</React.Suspense>
</TooltipProvider>
)
}

View File

@ -40,15 +40,11 @@ import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
import { AgentIcon } from '@/lib/agent-catalog'
import { formatWindowLabel } from '@/lib/window-label-formatter'
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
import { SshStatusSegment } from './SshStatusSegment'
import { UpdateStatusSegment } from './UpdateStatusSegment'
import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment'
import { PortsStatusSegment } from './PortsStatusSegment'
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
import { isProviderConfigured } from './status-bar-provider-visibility'
import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta'
import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy'
import { PetStatusSegment } from './PetStatusSegment'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
@ -64,6 +60,21 @@ type StatusBarProps = {
floatingTerminalOpen: boolean
}
const PetStatusSegment = React.lazy(() =>
import('./PetStatusSegment').then((module) => ({ default: module.PetStatusSegment }))
)
const ResourceUsageStatusSegment = React.lazy(() =>
import('./ResourceUsageStatusSegment').then((module) => ({
default: module.ResourceUsageStatusSegment
}))
)
const PortsStatusSegment = React.lazy(() =>
import('./PortsStatusSegment').then((module) => ({ default: module.PortsStatusSegment }))
)
const SshStatusSegment = React.lazy(() =>
import('./SshStatusSegment').then((module) => ({ default: module.SshStatusSegment }))
)
export type CodexStatusRuntimeTarget = {
runtime: 'host' | 'wsl'
wslDistro: string | null
@ -238,7 +249,7 @@ export function buildCodexStatusSwitchGroups(
targets: [
{
id: null,
label: translate("auto.components.status.bar.StatusBar.c676918adc", "System default"),
label: translate('auto.components.status.bar.StatusBar.c676918adc', 'System default'),
active: activeId === null,
runtimeTarget: target
},
@ -397,7 +408,7 @@ export function buildClaudeStatusSwitchGroups(
targets: [
{
id: null,
label: translate("auto.components.status.bar.StatusBar.c676918adc", "System default"),
label: translate('auto.components.status.bar.StatusBar.c676918adc', 'System default'),
active: activeId === null,
runtimeTarget: target
},
@ -511,11 +522,22 @@ function CodexRestartStatusPrompt(): React.JSX.Element | null {
bulk restart action operates per PTY-backed pane restart. Show
both counts so split panes do not make the number look wrong. */}
{staleCodexStatus.staleSessionCount === 1
? translate("auto.components.status.bar.StatusBar.605901a495", "1 Codex session is still on the old account")
: translate("auto.components.status.bar.StatusBar.1446d0d8a0", "{{value0}} Codex sessions are still on the old account.", { value0: staleCodexStatus.staleSessionCount })}
? translate(
'auto.components.status.bar.StatusBar.605901a495',
'1 Codex session is still on the old account'
)
: translate(
'auto.components.status.bar.StatusBar.1446d0d8a0',
'{{value0}} Codex sessions are still on the old account.',
{ value0: staleCodexStatus.staleSessionCount }
)}
{staleCodexStatus.staleWorktreeCount > 1 ? (
<span className="mt-0.5 block">
{translate("auto.components.status.bar.StatusBar.59c6e7b4e0", "Visible sessions restart now. Others restart when their worktree becomes active.")}</span>
{translate(
'auto.components.status.bar.StatusBar.59c6e7b4e0',
'Visible sessions restart now. Others restart when their worktree becomes active.'
)}
</span>
) : null}
</div>
<button
@ -524,8 +546,12 @@ function CodexRestartStatusPrompt(): React.JSX.Element | null {
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
{staleCodexStatus.staleSessionCount === 1
? translate("auto.components.status.bar.StatusBar.6cd6650b4c", "Restart Session")
: translate("auto.components.status.bar.StatusBar.cd9d7b40ff", "Restart {{value0}} Sessions", { value0: staleCodexStatus.staleSessionCount })}
? translate('auto.components.status.bar.StatusBar.6cd6650b4c', 'Restart Session')
: translate(
'auto.components.status.bar.StatusBar.cd9d7b40ff',
'Restart {{value0}} Sessions',
{ value0: staleCodexStatus.staleSessionCount }
)}
</button>
</div>
</>
@ -729,19 +755,27 @@ function ClaudeSwitcherMenu({
provider={claude}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate("auto.components.status.bar.StatusBar.3dd7ddfae1", "Open Claude details and account switcher")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.3dd7ddfae1',
'Open Claude details and account switcher'
)}
topContent={
<AccountRuntimeToggle
groups={switchGroups}
value={selectedGroup?.key ?? selectedRuntimeKey}
onChange={(group) => void handleSelectRuntime(group)}
ariaLabel={translate("auto.components.status.bar.StatusBar.11e2354daf", "Claude usage runtime")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.11e2354daf',
'Claude usage runtime'
)}
/>
}
open={open}
onOpenChange={handleOpenChange}
>
<DropdownMenuLabel>{translate("auto.components.status.bar.StatusBar.d450654fa2", "Claude Account")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.status.bar.StatusBar.d450654fa2', 'Claude Account')}
</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
@ -749,7 +783,8 @@ function ClaudeSwitcherMenu({
}}
>
<span className="max-w-[180px] truncate text-[12px] text-foreground">
{activeTarget?.label ?? translate("auto.components.status.bar.StatusBar.c676918adc", "System default")}
{activeTarget?.label ??
translate('auto.components.status.bar.StatusBar.c676918adc', 'System default')}
</span>
{accountsExpanded ? (
<ChevronDown className="ml-auto size-3.5 text-muted-foreground/85" />
@ -760,10 +795,13 @@ function ClaudeSwitcherMenu({
{accountsExpanded ? (
<div className="px-1 pb-1">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{translate("auto.components.status.bar.StatusBar.9332ba8684", "Switch to")}</div>
{translate('auto.components.status.bar.StatusBar.9332ba8684', 'Switch to')}
</div>
<div className="max-h-[220px] overflow-y-auto rounded-md border border-border/60 bg-accent/5 p-1 scrollbar-sleek">
{selectedGroup?.targets.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{translate("auto.components.status.bar.StatusBar.c98ea88392", "No other accounts")}</div>
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.c98ea88392', 'No other accounts')}
</div>
) : null}
{selectedGroup?.targets.map((target) => {
const inactiveUsage = target.id
@ -786,7 +824,8 @@ function ClaudeSwitcherMenu({
<span className="min-w-0 flex-1 truncate">{target.label}</span>
{target.active ? (
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
{translate("auto.components.status.bar.StatusBar.ff0fbe9311", "Active")}</span>
{translate('auto.components.status.bar.StatusBar.ff0fbe9311', 'Active')}
</span>
) : null}
</div>
{inactiveUsage?.isFetching && !inactiveUsage.rateLimits ? (
@ -803,7 +842,11 @@ function ClaudeSwitcherMenu({
})}
</div>
<div className="px-2 py-1.5 text-[10px] leading-4 text-muted-foreground">
{translate("auto.components.status.bar.StatusBar.8295903d17", "Restart live Claude terminals before continuing old conversations after switching.")}</div>
{translate(
'auto.components.status.bar.StatusBar.8295903d17',
'Restart live Claude terminals before continuing old conversations after switching.'
)}
</div>
</div>
) : null}
<DropdownMenuSeparator />
@ -817,7 +860,8 @@ function ClaudeSwitcherMenu({
openSettingsPage()
}}
>
{translate("auto.components.status.bar.StatusBar.75ded02687", "Manage Accounts…")}</DropdownMenuItem>
{translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')}
</DropdownMenuItem>
</ProviderDetailsMenu>
)
}
@ -864,7 +908,9 @@ function InlineUsageBars({
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{sessionLeft}{translate("auto.components.status.bar.StatusBar.d79c3362c4", "% 5h")}</span>
{sessionLeft}
{translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')}
</span>
</div>
)}
{weeklyLeft !== null && (
@ -876,11 +922,15 @@ function InlineUsageBars({
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{weeklyLeft}{translate("auto.components.status.bar.StatusBar.5c938d39ac", "% wk")}</span>
{weeklyLeft}
{translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')}
</span>
</div>
)}
{limits.status === 'error' && !limits.session && !limits.weekly && (
<span className="text-[10px] text-muted-foreground">{translate("auto.components.status.bar.StatusBar.f19a63e7cd", "Sign in to see usage")}</span>
<span className="text-[10px] text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
</span>
)}
</div>
)
@ -905,7 +955,9 @@ function InlineUsageSignInAction({
}): React.JSX.Element {
return (
<div className={`flex w-full items-center gap-2 ${isFetching ? 'animate-pulse' : ''}`}>
<span className="min-w-0 flex-1 text-[10px] text-muted-foreground">{translate("auto.components.status.bar.StatusBar.f19a63e7cd", "Sign in to see usage")}</span>
<span className="min-w-0 flex-1 text-[10px] text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
</span>
<Button
type="button"
variant="ghost"
@ -928,7 +980,8 @@ function InlineUsageSignInAction({
) : (
<RefreshCw className="size-3" />
)}
{translate("auto.components.status.bar.StatusBar.c35af53b73", "Sign in")}</Button>
{translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')}
</Button>
</div>
)
}
@ -978,7 +1031,9 @@ function ProviderSegment({
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
<span className="animate-pulse">{translate("auto.components.status.bar.StatusBar.2483c60695", "&middot;&middot;&middot;")}</span>
<span className="animate-pulse">
{translate('auto.components.status.bar.StatusBar.2483c60695', '&middot;&middot;&middot;')}
</span>
</span>
)
}
@ -988,7 +1043,9 @@ function ProviderSegment({
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
<span className="animate-pulse">{translate("auto.components.status.bar.StatusBar.2483c60695", "&middot;&middot;&middot;")}</span>
<span className="animate-pulse">
{translate('auto.components.status.bar.StatusBar.2483c60695', '&middot;&middot;&middot;')}
</span>
</span>
)
}
@ -1025,7 +1082,11 @@ function ProviderSegment({
const left = Math.max(0, Math.round(100 - bucket.usedPercent))
return (
<React.Fragment key={bucket.name}>
{i > 0 && <span className="text-muted-foreground">{translate("auto.components.status.bar.StatusBar.4dff061aab", "&middot;")}</span>}
{i > 0 && (
<span className="text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.4dff061aab', '&middot;')}
</span>
)}
<span className="tabular-nums">
{bucket.name} {left}%
</span>
@ -1047,7 +1108,11 @@ function ProviderSegment({
{p.session && (
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
)}
{p.session && p.weekly && <span className="text-muted-foreground">{translate("auto.components.status.bar.StatusBar.4dff061aab", "&middot;")}</span>}
{p.session && p.weekly && (
<span className="text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.4dff061aab', '&middot;')}
</span>
)}
{p.weekly && <WindowLabel w={p.weekly} label={formatWindowLabel(p.weekly.windowMinutes)} />}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
@ -1259,19 +1324,27 @@ function CodexSwitcherMenu({
provider={codex}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate("auto.components.status.bar.StatusBar.ba55303942", "Open Codex details and account switcher")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.ba55303942',
'Open Codex details and account switcher'
)}
topContent={
<AccountRuntimeToggle
groups={switchGroups}
value={selectedGroup?.key ?? selectedRuntimeKey}
onChange={(group) => void handleSelectRuntime(group)}
ariaLabel={translate("auto.components.status.bar.StatusBar.38b5647724", "Codex usage runtime")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.38b5647724',
'Codex usage runtime'
)}
/>
}
open={open}
onOpenChange={handleOpenChange}
>
<DropdownMenuLabel>{translate("auto.components.status.bar.StatusBar.7657e3db9c", "Codex Account")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.status.bar.StatusBar.7657e3db9c', 'Codex Account')}
</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
@ -1281,7 +1354,8 @@ function CodexSwitcherMenu({
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5 text-[12px]">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-foreground">
{activeTarget?.label ?? translate("auto.components.status.bar.StatusBar.c676918adc", "System default")}
{activeTarget?.label ??
translate('auto.components.status.bar.StatusBar.c676918adc', 'System default')}
</span>
</div>
</div>
@ -1331,7 +1405,11 @@ function CodexSwitcherMenu({
<span className="min-w-0 flex-1 truncate">{target.label}</span>
{target.active ? (
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
{translate("auto.components.status.bar.StatusBar.ff0fbe9311", "Active")}</span>
{translate(
'auto.components.status.bar.StatusBar.ff0fbe9311',
'Active'
)}
</span>
) : null}
</div>
{inactiveUsage?.isFetching && !inactiveUsage.rateLimits ? (
@ -1376,7 +1454,8 @@ function CodexSwitcherMenu({
openSettingsPage()
}}
>
{translate("auto.components.status.bar.StatusBar.75ded02687", "Manage Accounts…")}</DropdownMenuItem>
{translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')}
</DropdownMenuItem>
</ProviderDetailsMenu>
)
}
@ -1425,13 +1504,13 @@ export function ProviderDetailsMenu({
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
/>
<span className="text-muted-foreground">
{provider.provider === "claude"
{provider.provider === 'claude'
? 'C'
: provider.provider === "gemini"
: provider.provider === 'gemini'
? 'G'
: provider.provider === "opencode-go"
: provider.provider === 'opencode-go'
? 'O'
: provider.provider === "kimi"
: provider.provider === 'kimi'
? 'K'
: 'X'}
</span>
@ -1672,7 +1751,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
provider={gemini}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate("auto.components.status.bar.StatusBar.d2375976eb", "Open Gemini usage details")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.d2375976eb',
'Open Gemini usage details'
)}
/>
)}
{showOpencodeGo && (
@ -1680,7 +1762,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
provider={opencodeGo}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate("auto.components.status.bar.StatusBar.629251f4b6", "Open OpenCode Go usage details")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.629251f4b6',
'Open OpenCode Go usage details'
)}
/>
)}
{showKimi && (
@ -1688,7 +1773,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
provider={kimi}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate("auto.components.status.bar.StatusBar.fda8146810", "Open Kimi usage details")}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.fda8146810',
'Open Kimi usage details'
)}
/>
)}
</>
@ -1700,7 +1788,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
onClick={handleRefresh}
disabled={isRefreshing}
className="p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
aria-label={translate("auto.components.status.bar.StatusBar.3325d996cb", "Refresh rate limits")}
aria-label={translate(
'auto.components.status.bar.StatusBar.3325d996cb',
'Refresh rate limits'
)}
>
<RefreshCw
size={11}
@ -1709,7 +1800,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate("auto.components.status.bar.StatusBar.c8857b40f7", "Refresh usage data")}</TooltipContent>
{translate('auto.components.status.bar.StatusBar.c8857b40f7', 'Refresh usage data')}
</TooltipContent>
</Tooltip>
)}
</div>
@ -1718,10 +1810,14 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
<div className="flex items-center gap-3">
<UpdateStatusSegment compact={compact} iconOnly={iconOnly} />
{petEnabled && <PetStatusSegment />}
{showResourceUsage && <ResourceUsageStatusSegment compact={compact} iconOnly={iconOnly} />}
{showPorts && <PortsStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
<React.Suspense fallback={null}>
{petEnabled ? <PetStatusSegment /> : null}
{showResourceUsage ? (
<ResourceUsageStatusSegment compact={compact} iconOnly={iconOnly} />
) : null}
{showPorts ? <PortsStatusSegment compact={compact} iconOnly={iconOnly} /> : null}
{showSsh ? <SshStatusSegment compact={compact} iconOnly={iconOnly} /> : null}
</React.Suspense>
{showFloatingTerminalToggle && (
<FloatingTerminalIconContextMenu currentLocation="status-bar" className="relative">
<Tooltip>
@ -1764,7 +1860,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<ClaudeIcon size={14} />
{translate("auto.components.status.bar.StatusBar.3885eb74d8", "Claude Usage")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.3885eb74d8', 'Claude Usage')}
</DropdownMenuCheckboxItem>
)}
{isStatusBarItemAvailable('codex', detectedAgentIds) && (
<DropdownMenuCheckboxItem
@ -1775,7 +1872,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<OpenAIIcon size={14} />
{translate("auto.components.status.bar.StatusBar.c0909c686e", "Codex Usage")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.c0909c686e', 'Codex Usage')}
</DropdownMenuCheckboxItem>
)}
{isStatusBarItemAvailable('gemini', detectedAgentIds) && (
<DropdownMenuCheckboxItem
@ -1786,7 +1884,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<GeminiIcon size={14} />
{translate("auto.components.status.bar.StatusBar.c1df0d67ec", "Gemini Usage")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini Usage')}
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('opencode-go')}
@ -1796,7 +1895,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<OpenCodeGoIcon size={14} />
{translate("auto.components.status.bar.StatusBar.8c86cd77b0", "OpenCode Go Usage")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.8c86cd77b0', 'OpenCode Go Usage')}
</DropdownMenuCheckboxItem>
{isStatusBarItemAvailable('kimi', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('kimi')}
@ -1806,7 +1906,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<AgentIcon agent="kimi" size={14} />
{translate("auto.components.status.bar.StatusBar.5e59007df4", "Kimi Usage")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')}
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ssh')}
@ -1816,7 +1917,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<Server className="size-3.5" />
{translate("auto.components.status.bar.StatusBar.24ac89df1a", "SSH Status")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.24ac89df1a', 'SSH Status')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('resource-usage')}
onCheckedChange={() => {
@ -1825,7 +1927,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<Activity className="size-3.5" />
{translate("auto.components.status.bar.StatusBar.d1e1a7a6bf", "Resource Manager")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ports')}
onCheckedChange={() => {
@ -1834,7 +1937,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<Plug className="size-3.5" />
{translate("auto.components.status.bar.StatusBar.9659e38343", "Ports")}</DropdownMenuCheckboxItem>
{translate('auto.components.status.bar.StatusBar.9659e38343', 'Ports')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -815,6 +815,10 @@ export type UISlice = {
// rich content (title/media/description) during downloading, error, and downloaded
// states. Cleared on idle/checking/not-available to prevent stale leakage.
updateChangelog: ChangelogData | null
// Why: UpdateCard is lazy-loaded, so it may miss the transient
// checking/userInitiated status. Keep manual-check intent in the store until
// the resulting available/error/not-available state can consume it.
updateUserInitiatedCycle: boolean
dismissedUpdateVersion: string | null
dismissUpdate: (versionOverride?: string) => void
clearDismissedUpdateVersion: () => void
@ -938,7 +942,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
: s
)
const { toast } = await import('sonner')
toast.error(translate("auto.store.slices.ui.53883b7bc3", "Couldn't send to {{value0}}", { value0: label }), { description: message })
toast.error(
translate('auto.store.slices.ui.53883b7bc3', "Couldn't send to {{value0}}", {
value0: label
}),
{ description: message }
)
return false
}
@ -949,7 +958,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
launch_source: mode.launchSource,
request_kind: 'followup'
})
toast.success(translate("auto.store.slices.ui.66e3bd7ce6", "Sent to {{value0}}", { value0: label }))
toast.success(
translate('auto.store.slices.ui.66e3bd7ce6', 'Sent to {{value0}}', { value0: label })
)
get().closeAgentSendPopoverTargetMode(mode.id, mode.instanceId)
return true
},
@ -2030,10 +2041,18 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
setUpdateStatus: (status) => {
const prevState = get().updateStatus.state
const update: Partial<
Pick<UISlice, 'updateStatus' | 'updateChangelog' | 'updateCardCollapsed'>
Pick<
UISlice,
'updateStatus' | 'updateChangelog' | 'updateCardCollapsed' | 'updateUserInitiatedCycle'
>
> = {
updateStatus: status
}
if (status.state === 'checking') {
update.updateUserInitiatedCycle = status.userInitiated === true
} else if (status.state === 'idle') {
update.updateUserInitiatedCycle = false
}
if (status.state === 'available') {
// Why: cache changelog from each 'available' payload so the card retains
// rich content across downloading/error/downloaded transitions. Always
@ -2059,6 +2078,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set(update)
},
updateChangelog: null,
updateUserInitiatedCycle: false,
dismissedUpdateVersion: null,
clearDismissedUpdateVersion: () => {
set({ dismissedUpdateVersion: null })
@ -2081,7 +2101,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
if (activeNudgeId) {
void window.api.updater.dismissNudge().catch(console.error)
}
return { dismissedUpdateVersion }
return { dismissedUpdateVersion, updateUserInitiatedCycle: false }
}),
updateCardCollapsed: false,
setUpdateCardCollapsed: (collapsed) => set({ updateCardCollapsed: collapsed }),

View File

@ -417,6 +417,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
relaunch: () => Promise.resolve(window.location.reload()),
restart: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
awaitFirstWindowStartupServices: () => Promise.resolve(),
getKeyboardInputSourceId: () => Promise.resolve(null),
setUnreadDockBadgeCount: () => Promise.resolve(),
getFloatingTerminalCwd: () => Promise.resolve(''),
@ -464,9 +465,13 @@ function createWebPreloadApi(): Partial<PreloadApi> {
Promise.resolve({
ok: false,
status: null,
error: translate("auto.web.web.preload.api.fb290366b2", "Unavailable on web.")
error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.')
}),
copyLatestDiagnostics: () => Promise.resolve({ ok: false, error: translate("auto.web.web.preload.api.fb290366b2", "Unavailable on web.") })
copyLatestDiagnostics: () =>
Promise.resolve({
ok: false,
error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.')
})
},
diagnostics: {
getStatus: () =>
@ -641,7 +646,13 @@ function normalizeStoredWebOverrides(
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({ severity: 'error', section, message: translate("auto.web.web.preload.api.d2e43e426a", "{{value0}} must be an object.", { value0: section }) })
diagnostics.push({
severity: 'error',
section,
message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', {
value0: section
})
})
return {}
}
@ -652,7 +663,11 @@ function normalizeStoredWebOverrides(
severity: 'warning',
section,
actionId,
message: translate("auto.web.web.preload.api.36761d9604", "Unknown keybinding action \"{{value0}}\" was ignored.", { value0: actionId })
message: translate(
'auto.web.web.preload.api.36761d9604',
'Unknown keybinding action "{{value0}}" was ignored.',
{ value0: actionId }
)
})
continue
}
@ -664,7 +679,11 @@ function normalizeStoredWebOverrides(
severity: 'error',
section,
actionId,
message: translate("auto.web.web.preload.api.10898045f3", "Shortcut for \"{{value0}}\" was ignored: Use a string array.", { value0: actionId })
message: translate(
'auto.web.web.preload.api.10898045f3',
'Shortcut for "{{value0}}" was ignored: Use a string array.',
{ value0: actionId }
)
})
continue
}
@ -675,7 +694,11 @@ function normalizeStoredWebOverrides(
severity: 'error',
section,
actionId,
message: translate("auto.web.web.preload.api.10898045f3", "Shortcut for \"{{value0}}\" was ignored: {{value1}}", { value0: actionId, value1: error })
message: translate(
'auto.web.web.preload.api.10898045f3',
'Shortcut for "{{value0}}" was ignored: {{value1}}',
{ value0: actionId, value1: error }
)
})
continue
}
@ -695,7 +718,10 @@ function normalizeWebPlatformOverrides(
diagnostics.push({
severity: 'error',
section: 'platforms',
message: translate("auto.web.web.preload.api.0a69fcd8bc", "platforms must be an object with darwin, linux, or win32 sections.")
message: translate(
'auto.web.web.preload.api.0a69fcd8bc',
'platforms must be an object with darwin, linux, or win32 sections.'
)
})
return {}
}
@ -706,7 +732,11 @@ function normalizeWebPlatformOverrides(
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: translate("auto.web.web.preload.api.32f15bdb0f", "Unknown platform \"{{value0}}\" was ignored.", { value0: platform })
message: translate(
'auto.web.web.preload.api.32f15bdb0f',
'Unknown platform "{{value0}}" was ignored.',
{ value0: platform }
)
})
continue
}
@ -743,9 +773,15 @@ function removeConflictingWebOverrides(
}
diagnostics.push({
severity: 'error',
message: translate("auto.web.web.preload.api.52bee9d8a0", "Conflicting custom shortcuts were ignored: {{value0}}.", { value0: Array.from(conflictingOverrides)
.map((actionId) => actionId)
.join(', ') })
message: translate(
'auto.web.web.preload.api.52bee9d8a0',
'Conflicting custom shortcuts were ignored: {{value0}}.',
{
value0: Array.from(conflictingOverrides)
.map((actionId) => actionId)
.join(', ')
}
)
})
}
return next
@ -1371,16 +1407,25 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
},
generateCommitMessage: async () => ({
success: false,
error: translate("auto.web.web.preload.api.9fc90740b6", "Commit message generation is unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.9fc90740b6',
'Commit message generation is unavailable in the web client.'
)
}),
discoverCommitMessageModels: async () => ({
success: false,
error: translate("auto.web.web.preload.api.e57c82d276", "Commit message model discovery is unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.e57c82d276',
'Commit message model discovery is unavailable in the web client.'
)
}),
cancelGenerateCommitMessage: () => Promise.resolve(),
generatePullRequestFields: async () => ({
success: false,
error: translate("auto.web.web.preload.api.b8a1618172", "Pull request detail generation is unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.b8a1618172',
'Pull request detail generation is unavailable in the web client.'
)
}),
cancelGeneratePullRequestFields: () => Promise.resolve(),
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
@ -1431,17 +1476,38 @@ function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
Promise.resolve({ ok: false, reason: 'Downloads are handled by the server browser.' }),
cancelDownload: () => Promise.resolve(false),
setGrabMode: () =>
Promise.resolve({ ok: false, error: translate("auto.web.web.preload.api.31bea294d5", "Grab mode is unavailable in the web client.") }),
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.31bea294d5',
'Grab mode is unavailable in the web client.'
)
}),
awaitGrabSelection: () =>
Promise.resolve({ ok: false, error: translate("auto.web.web.preload.api.31bea294d5", "Grab mode is unavailable in the web client.") }),
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.31bea294d5',
'Grab mode is unavailable in the web client.'
)
}),
cancelGrab: () => Promise.resolve(false),
captureSelectionScreenshot: () =>
Promise.resolve({
ok: false,
error: translate("auto.web.web.preload.api.8dfcb7a351", "Selection screenshots are unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.8dfcb7a351',
'Selection screenshots are unavailable in the web client.'
)
}),
extractHoverPayload: () =>
Promise.resolve({ ok: false, error: translate("auto.web.web.preload.api.275a776357", "Hover extraction is unavailable in the web client.") }),
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.275a776357',
'Hover extraction is unavailable in the web client.'
)
}),
onGrabModeToggle: () => noopUnsubscribe,
onGrabActionShortcut: () => noopUnsubscribe,
sessionListProfiles: () => Promise.resolve([]),
@ -1451,7 +1517,10 @@ function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
Promise.resolve({
ok: false,
summary: null,
error: translate("auto.web.web.preload.api.67ec964791", "Cookie import is unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.67ec964791',
'Cookie import is unavailable in the web client.'
)
}),
sessionResolvePartition: () => Promise.resolve(null),
sessionDetectBrowsers: () => Promise.resolve([]),
@ -1459,7 +1528,10 @@ function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
Promise.resolve({
ok: false,
summary: null,
error: translate("auto.web.web.preload.api.67ec964791", "Cookie import is unavailable in the web client.")
error: translate(
'auto.web.web.preload.api.67ec964791',
'Cookie import is unavailable in the web client.'
)
}),
sessionClearDefaultCookies: () => Promise.resolve(false),
notifyActiveTabChanged: () => Promise.resolve(false)
@ -1570,7 +1642,10 @@ function createGitHubApi(): WebGitHubApi {
rateLimit: (args) =>
route<WebGitHubResult<'rateLimit'>>(GITHUB_WEB_RPC_METHODS.rateLimit, args),
diagnoseAuth: () =>
Promise.resolve({ ok: false, message: translate("auto.web.web.preload.api.31bfe8ae1a", "Unavailable in the web client.") } as never),
Promise.resolve({
ok: false,
message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.')
} as never),
listAccessibleProjects: () =>
route<WebGitHubResult<'listAccessibleProjects'>>(
GITHUB_WEB_RPC_METHODS.listAccessibleProjects
@ -2234,7 +2309,10 @@ function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
getState: () => Promise.resolve(null),
needsPassphrasePrompt: () => Promise.resolve(false),
testConnection: () =>
Promise.resolve({ success: false, error: translate("auto.web.web.preload.api.31bfe8ae1a", "Unavailable in the web client.") }),
Promise.resolve({
success: false,
error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.')
}),
onStateChanged: () => noopUnsubscribe,
addPortForward: () =>
Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')),