From 94662c84451d10e229ca77434353a051cffefea9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:17:35 -0700 Subject: [PATCH 01/25] feat(codex): write in-Codex setting changes back to ~/.codex config (#7960) Co-authored-by: Orca --- config/tsconfig.cli.json | 1 + src/main/codex/codex-config-mirror.ts | 12 + .../codex/config-settings-promotion.test.ts | 296 ++++++++++++++++++ src/main/codex/config-settings-promotion.ts | 242 ++++++++++++++ 4 files changed, 551 insertions(+) create mode 100644 src/main/codex/config-settings-promotion.test.ts create mode 100644 src/main/codex/config-settings-promotion.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 21cae9319cc..0fd87a36f7c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,7 @@ "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index ce9208e9b05..605f2cd5f24 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,6 +3,10 @@ import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { + promoteCodexRuntimeSettingsToSystem, + snapshotCodexRuntimeSettingsBaseline +} from './config-settings-promotion' import { createTomlLineScanState, getTomlTableHeader, @@ -19,11 +23,19 @@ function getSystemCodexConfigTomlPath(): string { } export function syncSystemConfigIntoManagedCodexHome(): void { + // Why: the mirror overwrites runtime settings from ~/.codex, so changes the + // user made inside Orca-launched Codex (/model, /approvals) must be written + // back to ~/.codex first or this very pass silently reverts them. + promoteCodexRuntimeSettingsToSystem() try { syncSystemConfigIntoManagedCodexHomeUnsafe() } catch (error) { console.warn('[codex-config] Failed to mirror system Codex config:', error) + return } + // Why: the baseline advances only after a successful mirror; recording an + // unpromoted runtime change as Orca-written would strand it forever. + snapshotCodexRuntimeSettingsBaseline() } function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts new file mode 100644 index 00000000000..f81866d8751 --- /dev/null +++ b/src/main/codex/config-settings-promotion.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { upsertTopLevelSettingsInContent } from './config-settings-promotion' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + // Why: promotion writes into homedir()/.codex — if the mock ever fails to + // intercept, these tests would rewrite the developer's real Codex config. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function systemConfigPath(): string { + return join(tmpHome, '.codex', 'config.toml') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function runtimeConfigPath(): string { + return join(runtimeHomeDir(), 'config.toml') +} + +function baselinePath(): string { + return join(runtimeHomeDir(), '.orca-config-settings-baseline.json') +} + +function writeSystemConfig(content: string): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + writeFileSync(systemConfigPath(), content, 'utf-8') +} + +function readSystemConfig(): string { + return readFileSync(systemConfigPath(), 'utf-8') +} + +function readRuntimeConfig(): string { + return readFileSync(runtimeConfigPath(), 'utf-8') +} + +// Mimics how Codex (toml_edit) persists a /model or /approvals change: the +// top-level key line is rewritten in place, or created when absent. +function simulateCodexSettingWrite(key: string, rawValue: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + const existing = existsSync(runtimeConfigPath()) ? readFileSync(runtimeConfigPath(), 'utf-8') : '' + const linePattern = new RegExp(`^${key}[ \\t]*=.*$`, 'm') + const rendered = `${key} = ${rawValue}` + const next = linePattern.test(existing) + ? existing.replace(linePattern, rendered) + : `${rendered}\n${existing}` + writeFileSync(runtimeConfigPath(), next, 'utf-8') +} + +function simulateCodexSettingRemoval(key: string): void { + const existing = readFileSync(runtimeConfigPath(), 'utf-8') + const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm') + writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8') +} + +describe('codex settings write-back promotion', () => { + it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => { + writeSystemConfig( + 'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('promotes multiple approvals keys in one pass', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + simulateCodexSettingWrite('sandbox_mode', '"danger-full-access"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('approval_policy = "never"') + expect(system).toContain('sandbox_mode = "danger-full-access"') + expect(system).toContain('model = "gpt-5"') + }) + + it('does not promote on the first pass without a baseline, then promotes after one', () => { + writeSystemConfig('model = "gpt-5"\n') + mkdirSync(runtimeHomeDir(), { recursive: true }) + // Pre-upgrade state: runtime already diverged, but no baseline exists. + writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('treats a corrupt baseline as missing and rewrites it', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + writeFileSync(baselinePath(), 'not json', 'utf-8') + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 }) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"in-codex-choice"') + writeSystemConfig('model = "outside-edit"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('model = "outside-edit"') + }) + + it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => { + writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"on-request"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + '# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + }) + + it('creates ~/.codex/config.toml when a user without one changes a setting', () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + // Codex itself creates the runtime config.toml on the first /model write. + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + }) + + it('does not promote a key deletion', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingRemoval('model') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + }) + + it('ignores keys outside the allowlist', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('notify', '["custom-notifier"]') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + }) + + it('ignores allowlisted keys inside tables such as [profiles.*]', () => { + writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + syncSystemConfigIntoManagedCodexHome() + + const runtime = readRuntimeConfig() + writeFileSync( + runtimeConfigPath(), + runtime.replace('model = "profile-model"', 'model = "profile-changed"'), + 'utf-8' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + }) + + it('never rewrites a multiline system value', () => { + writeSystemConfig('model = """\nodd\nmultiline\n"""\n') + syncSystemConfigIntoManagedCodexHome() + + writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n') + }) + + it('preserves CRLF line endings when replacing a value', () => { + writeSystemConfig('model = "gpt-5"\r\napproval_policy = "on-request"\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('model = "o4"\r\n') + expect(readSystemConfig()).toContain('approval_policy = "on-request"\r\n') + }) + + it('promotes over a value that carried an inline comment', () => { + writeSystemConfig('model = "gpt-5" # my favorite\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + }) +}) + +describe('upsertTopLevelSettingsInContent', () => { + it('writes into empty content', () => { + expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n') + }) + + it('inserts before the first table with a separating blank line', () => { + expect( + upsertTopLevelSettingsInContent('[features]\nhooks = true\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\n\n[features]\nhooks = true\n') + }) + + it('appends to a preamble-only file without a trailing newline', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\nmodel = "x"\n') + }) + + it('replaces the existing line in place', () => { + expect( + upsertTopLevelSettingsInContent( + '# keep\nmodel = "old"\n\n[t]\nk = 1\n', + new Map([['model', '"new"']]) + ) + ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts new file mode 100644 index 00000000000..934d57ed39e --- /dev/null +++ b/src/main/codex/config-settings-promotion.ts @@ -0,0 +1,242 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +// Why: the config mirror rewrites the runtime config.toml from ~/.codex on +// every launch (and on background rate-limit fetches), so settings the user +// changes inside Orca-launched Codex silently revert. Promotion diffs the +// runtime file against a baseline of what Orca last wrote — anything that +// differs is a change Codex persisted for the user and belongs in ~/.codex. + +// Why: only the user-preference scalars the Codex TUI itself persists +// (/model writes model + model_reasoning_effort, /approvals writes +// approval_policy + sandbox_mode). Every key added here gets written into the +// user's real ~/.codex/config.toml, so grow this list deliberately. +export const PROMOTED_CODEX_SETTING_KEYS = [ + 'model', + 'model_reasoning_effort', + 'approval_policy', + 'sandbox_mode' +] as const + +type TopLevelSettingValue = { + raw: string + // Why: a value that opens a multiline string/array cannot be replaced or + // copied line-by-line safely, so it is excluded from promotion entirely. + multiline: boolean +} + +type SettingsBaselineFile = { + version: 1 + settings: Record +} + +function getSettingsBaselinePath(runtimeHomePath: string): string { + return join(runtimeHomePath, '.orca-config-settings-baseline.json') +} + +function readSettingsBaseline(runtimeHomePath: string): Map | null { + const baselinePath = getSettingsBaselinePath(runtimeHomePath) + if (!existsSync(baselinePath)) { + return null + } + try { + const parsed: unknown = JSON.parse(readFileSync(baselinePath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const settings = (parsed as SettingsBaselineFile).settings + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return null + } + const result = new Map() + for (const [key, value] of Object.entries(settings)) { + if (typeof value === 'string') { + result.set(key, value) + } + } + return result + } catch { + return null + } +} + +// Why: only keys in the top-level preamble are scanned — Codex writes profile +// overrides into [profiles.*] tables, and rewriting nested tables surgically +// is not worth the risk for stage-1 promotion. +function readTopLevelSettingValues(configPath: string): Map { + const result = new Map() + if (!existsSync(configPath)) { + return result + } + const lines = readFileSync(configPath, 'utf-8').split('\n') + let state = createTomlLineScanState() + for (const line of lines) { + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line) + const key = match?.[1] + if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) { + const nextState = updateTomlLineScanState(state, line) + result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) }) + state = nextState + continue + } + } + state = updateTomlLineScanState(state, line) + } + return result +} + +/** + * Records the promotable top-level settings the runtime config.toml holds + * after a mirror, so the next promotion can tell "value Orca mirrored" apart + * from "value Codex wrote for the user". Call after a successful mirror only — + * advancing the baseline past an unpromoted change would strand it forever. + */ +export function snapshotCodexRuntimeSettingsBaseline(): void { + try { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + // Why: a missing runtime config still records an empty baseline — when + // Codex later creates the file for a user with no ~/.codex/config.toml, + // that first change must diff against "Orca left nothing" and promote. + const settings: Record = {} + for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { + if (!value.multiline) { + settings[key] = value.raw + } + } + const file: SettingsBaselineFile = { version: 1, settings } + writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + } catch (error) { + console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) + } +} + +/** + * Promotes setting changes the user made inside Orca-launched Codex (written + * by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs + * before the config mirror so the promoted values survive the same mirror + * pass instead of reverting. + */ +export function promoteCodexRuntimeSettingsToSystem(): void { + try { + promoteCodexRuntimeSettingsToSystemUnsafe() + } catch (error) { + // Why: promotion is best-effort launch prep; a malformed runtime file + // must not block the config mirror or the Codex launch itself. + console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + } +} + +function promoteCodexRuntimeSettingsToSystemUnsafe(): void { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const systemHomePath = getSystemCodexHomePath() + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + const systemTomlPath = join(systemHomePath, 'config.toml') + if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) { + return + } + if (!existsSync(runtimeTomlPath)) { + return + } + // Why: without a baseline of what Orca last mirrored (first launch after + // upgrading to a build with promotion, or a corrupted snapshot), a stale + // runtime value is indistinguishable from a fresh in-Codex change. Skip + // this pass — the mirror writes the first baseline and promotion starts on + // the next one. + const baseline = readSettingsBaseline(runtimeHomePath) + if (!baseline) { + return + } + const runtimeValues = readTopLevelSettingValues(runtimeTomlPath) + const systemValues = readTopLevelSettingValues(systemTomlPath) + const updates = new Map() + for (const key of PROMOTED_CODEX_SETTING_KEYS) { + const runtime = runtimeValues.get(key) + if (!runtime || runtime.multiline) { + continue + } + if (runtime.raw === baseline.get(key)) { + // Orca mirrored this value and nothing touched it since — not a change. + continue + } + const system = systemValues.get(key) + if (system?.multiline) { + continue + } + // Why: ~/.codex stays source of truth — if the user also edited it there + // since the baseline, the outside edit wins over the in-Codex change. + if (system?.raw !== baseline.get(key)) { + continue + } + updates.set(key, runtime.raw) + } + if (updates.size === 0) { + return + } + const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : '' + writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates)) +} + +export function upsertTopLevelSettingsInContent( + content: string, + updates: Map +): string { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + preambleEnd = index + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line) + if (match?.[1] && updates.has(match[1])) { + keyLineIndexes.set(match[1], index) + } + } + state = updateTomlLineScanState(state, line) + } + + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + // Why: CRLF configs keep a trailing \r after the split; preserve it so + // the rewritten line matches the file's existing endings. + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(rendered) + } + } + if (insertions.length > 0) { + let insertAt = preambleEnd + while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + if (insertAt === preambleEnd && preambleEnd < lines.length) { + insertions.push('') + } + lines.splice(insertAt, 0, ...insertions) + } + const result = lines.join('\n') + return result.endsWith('\n') || result.length === 0 ? result : `${result}\n` +} From 41edc493b80f1e0927f0d5be90b23e0225cc6be4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:37:51 +0000 Subject: [PATCH 02/25] release: v1.4.131-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0ae48e60ee..e6ad02b2d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.0", + "version": "1.4.131-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 1b0febc4cce798334654e519ef827bc1fdc28cb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:05:32 -0700 Subject: [PATCH 03/25] Wake slept agents when opening a worktree on mobile (#7906) Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 84 ++++++++++++++ src/main/runtime/orca-runtime.ts | 14 ++- src/main/runtime/rpc/methods/worktree.test.ts | 27 ++++- src/main/runtime/rpc/methods/worktree.ts | 7 +- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 6 + .../terminal-pane/pty-connection.test.ts | 58 ++++++++++ .../terminal-pane/pty-connection.ts | 10 ++ .../use-terminal-pane-lifecycle.ts | 37 ++++++- src/renderer/src/constants/terminal.ts | 11 ++ src/renderer/src/hooks/useIpcEvents.test.ts | 11 ++ src/renderer/src/hooks/useIpcEvents.ts | 9 ++ ...-agent-session-suppress-navigation.test.ts | 87 +++++++++++++++ .../src/lib/resume-sleeping-agent-session.ts | 21 +++- ...wake-sleeping-agents-in-background.test.ts | 103 ++++++++++++++++++ .../lib/wake-sleeping-agents-in-background.ts | 57 ++++++++++ src/renderer/src/web/web-preload-api.ts | 3 + 18 files changed, 532 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts create mode 100644 src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts create mode 100644 src/renderer/src/lib/wake-sleeping-agents-in-background.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cf087197949..7cf71bea957 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14772,6 +14772,90 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).not.toHaveBeenCalled() }) + it('wakes slept agents on the host renderer when a phone activates a worktree', async () => { + // Seed isUnread:false so the unread-clear branch stays quiet and the + // assertions isolate the mobile slept-agent wake. + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const activateWorktree = vi.fn() + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // A renderer must be attached to receive the wake (headless serve is a + // deliberate non-goal — no renderer, no wake). + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + // INV-2: mobile wake never navigates the desktop (no activateWorktree); it + // routes exclusively through the renderer's own navigation-free wake. + expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(activateWorktree).not.toHaveBeenCalled() + }) + + it('does not wake slept agents for non-mobile session-only activation', async () => { + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + // INV-3: web/desktop runtime clients keep their existing wake-on-activation + // paths untouched — the renderer notifier wake is mobile-scoped. + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { // Why: seed instanceId so worktree resolution does not emit its own // metadata-stamp write, isolating the assertion to the unread clear. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6bf085ff8f6..9681ea33e14 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1293,6 +1293,11 @@ type RuntimeNotifier = { ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void sleepWorktree(worktreeId: string): void + // Why: a phone opening a worktree wakes its slept agents by asking the host + // renderer to run its own navigation-free wake (experimental agent sleep); + // the runtime has no in-memory sleeping records or wake authority. Optional to + // match the many renderer-backed notifier methods only the real bridge wires. + resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -12587,7 +12592,7 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean } = {} + opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} ): Promise<{ repoId: string worktreeId: string @@ -12619,6 +12624,13 @@ export class OrcaRuntimeService { }) await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) + // Why: a phone open must also wake the worktree's slept agents (experimental + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected, and only when a renderer is attached to receive it. + if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + } } return { repoId: repo.id, worktreeId: worktree.id, activated: true } } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 60836205ef8..ee4e011023a 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -38,7 +38,32 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { - notifyClients: false + notifyClients: false, + clientKind: undefined + }) + }) + + it('forwards the mobile clientKind to the runtime on session-only activation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateManagedWorktree: vi + .fn() + .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // The mobile WebSocket path always uses dispatchStreaming, which threads the + // authenticated device scope as clientKind even for non-streaming methods. + const replies: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('worktree.activate', { worktree: 'id:wt-1', notifyClients: false }), + (response) => replies.push(response), + { clientKind: 'mobile' } + ) + + expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { + notifyClients: false, + clientKind: 'mobile' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index a95d6ebf00a..f84a26d1d87 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -59,9 +59,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.activate', params: WorktreeActivate, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind }) => + // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent + // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientKind }) }), defineMethod({ diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2d34a4c9fd0..cc03c40a904 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -389,6 +389,7 @@ function registerRuntimeWindowLifecycle( }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5c3bea5e55d..4ec3557beeb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2750,6 +2750,7 @@ export type PreloadApi = { callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 39756074c74..d7385f18fc2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3478,6 +3478,12 @@ const api = { ipcRenderer.on('ui:sleepWorktree', listener) return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ce885163fed..63a28b7819a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1872,6 +1872,64 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) }) + it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { + // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane + // (no isVisible flip): the armed cold-restore --resume must fire exactly once + // even when the wake is delivered twice (INV-1 idempotency). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-bg' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + // Still hidden: no reveal happened, so nothing respawned on exit. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg') + + // A second navigation-free wake must not spawn again (one-pane/one-PTY). + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { // Race: the user reveals the background tab in the window between the // coordinator confirming the candidate and the kill's exit arriving. The diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2d534757a41..df753a47114 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -673,6 +673,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + /** Navigation-free hibernation wake: fires the armed cold-restore --resume + * without the size-reassert/foreground-sample side effects of a real reveal. + * Used by the mobile wake fanout so a hidden hibernated pane resumes with no + * desktop hidden→visible transition. */ + wakeHibernatedAgentIfArmed: () => void /** Re-sample process identity when the pane gains intra-tab focus: the tab * icon follows the active leaf, and a shell-marked entry on a still-running * agent pane has no OSC boundary left to correct it. */ @@ -6244,6 +6249,11 @@ export function connectPanePty( consumeHibernatedAgentWake() sampleVisiblePaneForegroundAgent() }, + // Why: mobile wake reaches this pane while it stays hidden on the desktop, so + // it must consume only the armed hibernation wake — no size/foreground reads. + wakeHibernatedAgentIfArmed() { + consumeHibernatedAgentWake() + }, sampleForegroundAgentOnFocus() { sampleVisiblePaneForegroundAgent() }, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index e342778eed5..4f9d93ed934 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -113,8 +113,10 @@ import { import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, type SplitTerminalPaneDetail, - type CloseTerminalPaneDetail + type CloseTerminalPaneDetail, + type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' @@ -881,11 +883,12 @@ export function useTerminalPaneLifecycle({ imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { const now = Date.now() - const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( - e, - pendingTerminalImeCandidateKeyReleases, - now - ) + const pendingCandidateReleaseGuardActive = + shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) const imeKeyboardOptions = { compositionActive: imeCompositionTracker.isActive(), candidateKeyGuardActive: @@ -1746,6 +1749,28 @@ export function useTerminalPaneLifecycle({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabId, cwd]) + // Why: mobile wake fanout — this pane self-selects by worktreeId and fires its + // own armed hibernation --resume while staying hidden on the desktop (no + // reveal, no focus/navigation change). Not-yet-mounted panes are covered by + // the background-mount fresh-connect cold-restore path instead. + useEffect(() => { + const onWakeHibernatedAgents = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.worktreeId !== worktreeId) { + return + } + for (const panePtyBinding of panePtyBindingsRef.current.values()) { + ;( + panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void } + ).wakeHibernatedAgentIfArmed?.() + } + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + return () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + } + }, [worktreeId, panePtyBindingsRef]) + useEffect(() => { const previousIsVisible = getPreviousVisibleForTerminalPane({ previous: previousVisibleForReconcileRef.current, diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 0baa8a777e7..ed1a89aa634 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -8,6 +8,13 @@ export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-ter export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane' export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree' +// Why: mobile wake (experimental agent sleep) must fire the cold-restore +// --resume of a worktree's mounted hidden hibernated panes without a desktop +// hidden→visible reveal. Each mounted TerminalPane self-selects on this event +// by worktreeId and invokes its own armed hibernation wake — a fanout, since +// pane bindings are per-instance with no global registry. +export const WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT = 'orca-wake-hibernated-agents-worktree' + // Why: sidebar open/close is an instantaneous width change. If we wait for // the ResizeObserver rAF (and the 150ms debounced global fit) to catch up, // the user sees the terminal in a wrongly-fit state for ~16ms+ then a snap @@ -68,3 +75,7 @@ export type CloseTerminalPaneDetail = { export type BackgroundMountTerminalWorktreeDetail = { worktreeId: string } + +export type WakeHibernatedAgentsWorktreeDetail = { + worktreeId: string +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0623acbba53..c4c51907ccf 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -978,6 +978,7 @@ describe('useIpcEvents browser tab create routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: ( @@ -1198,6 +1199,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1441,6 +1443,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1928,6 +1931,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -2779,6 +2783,7 @@ describe('useIpcEvents browser tab close routing', () => { return () => {} }, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3257,6 +3262,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3473,6 +3479,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3684,6 +3691,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3922,6 +3930,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4168,6 +4177,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4400,6 +4410,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e318b7120d2..13202f13ac3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -9,6 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { wakeSleepingAgentsForWorktreeInBackground } from '@/lib/wake-sleeping-agents-in-background' import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, @@ -1942,6 +1943,14 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { + // Why: a phone opened this worktree; wake its slept agents on the host + // renderer navigation-free (no desktop worktree/tab/view change). + wakeSleepingAgentsForWorktreeInBackground(worktreeId) + }) + ) + // Hydrate initial update status then subscribe to changes window.api.updater.getStatus().then((status) => { useAppStore.getState().setUpdateStatus(status as UpdateStatus) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts new file mode 100644 index 00000000000..bc5affb0c50 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +function makeRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): Record { + return { + id, + ptyId: null, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => { + it('resumes without navigating the desktop when navigation is suppressed', () => { + // Mobile-scoped wake: the desktop sits on a different worktree/view. The + // resume must spawn the recovery tab without changing the active surface. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-other', + activeTabId: 'other-tab', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-other': 'other-tab' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1', { suppressNavigation: true }) + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + // A resume tab is created for the slept worktree... + expect(resumedTab?.launchAgent).toBe('claude') + // ...but the desktop's active worktree/tab/view are untouched (INV-2). + expect(state.activeWorktreeId).toBe('wt-other') + expect(state.activeTabId).toBe('other-tab') + expect(state.activeTabType).toBe('browser') + }) + + it('still navigates to the resumed tab for default (desktop) callers', () => { + // Regression guard: the suppress-navigation flag must be opt-in — desktop + // resume keeps flipping the active view to the recovered terminal. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabId: 'tab-1', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-1': 'tab-1' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(useAppStore.getState().activeTabType).toBe('terminal') + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 3579ca80400..ba283ab370b 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -57,7 +57,12 @@ function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { state.setTabBarOrder(worktreeId, order) } -function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean { +// Why: mobile-driven wake runs on the desktop host renderer, so it must create +// the resume tab without stealing the desktop's active worktree/tab/view. +function launchSleepingAgentSession( + record: SleepingAgentSessionRecord, + options?: { suppressNavigation?: boolean } +): boolean { const state = useAppStore.getState() const launchConfig = record.launchConfig const startupPlan = buildAgentResumeStartupPlan({ @@ -86,7 +91,8 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean } const tab = state.createTab(record.worktreeId, undefined, undefined, { - launchAgent: record.agent + launchAgent: record.agent, + ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, @@ -110,7 +116,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean providerSession: record.providerSession }) state.clearSleepingAgentSession(record.paneKey) - state.setActiveTabType('terminal') + if (!options?.suppressNavigation) { + state.setActiveTabType('terminal') + } appendTabToWorktreeOrder(record.worktreeId, tab.id) return true } @@ -239,7 +247,10 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): ) } -export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { +export function resumeSleepingAgentSessionsForWorktree( + worktreeId: string, + options?: { suppressNavigation?: boolean } +): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) @@ -298,7 +309,7 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb if (isPaneOwned) { continue } - if (launchSleepingAgentSession(record)) { + if (launchSleepingAgentSession(record, options)) { launched += 1 freshlyLaunchedClaimKeys.add(claimKey) clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts new file mode 100644 index 00000000000..4fbe281dfbc --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT +} from '@/constants/terminal' + +const resumeSpy = vi.fn() +vi.mock('./resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) => + resumeSpy(worktreeId, options) +})) + +// Why: control passive-vs-non-passive classification directly so the test asserts +// the gating, not the predicate internals. +const isPassiveSpy = vi.fn() +vi.mock('./sleeping-agent-pane-ownership', () => ({ + isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record) +})) + +let sleepingRecords: Record = {} +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords }) + } +})) + +import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' + +function recordEvents(): { events: string[]; stop: () => void } { + const events: string[] = [] + const onWake = (event: Event): void => { + events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + const onMount = (event: Event): void => { + events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + return { + events, + stop: () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + } + } +} + +beforeEach(() => { + sleepingRecords = {} + isPassiveSpy.mockReset() + resumeSpy.mockReset() +}) + +afterEach(() => { + resumeSpy.mockReset() +}) + +describe('wakeSleepingAgentsForWorktreeInBackground', () => { + it('fires wake, background-mount, then resume when a passive record exists', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // (a) pane-level wake of mounted hidden panes fires before (b) background-mount + // of not-yet-mounted panes. + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + // (c) non-passive records resume with navigation suppressed (INV-2). + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('skips background-mount when only non-passive records exist', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(false) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: no passive record → no not-yet-mounted pane to fresh-connect, so + // background-mount must not run (it would strand a plain shell / mount work). + expect(rec.events).toEqual(['wake:wt-1']) + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('does nothing when the worktree has no sleeping records', () => { + sleepingRecords = { k1: { worktreeId: 'other-wt' } } + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: mobile browsing a worktree with nothing slept must not mount it (and + // its PTYs) on the desktop host. + expect(rec.events).toEqual([]) + expect(resumeSpy).not.toHaveBeenCalled() + expect(isPassiveSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts new file mode 100644 index 00000000000..05369adbfe1 --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -0,0 +1,57 @@ +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' + +/** + * Wakes a worktree's slept agents on the desktop host renderer with NO desktop + * navigation — used when a phone (`clientKind: 'mobile'`) opens the worktree. + * Runs up to three steps, in order: + * (a) fire the armed cold-restore `--resume` of the worktree's mounted hidden + * hibernated panes (the experimental agent-sleep records; the primary + * wake mechanism, since those records are passive for path C); + * (b) background-mount so a hibernated pane that is NOT currently mounted + * (post-restart / evicted) mounts offscreen and takes the fresh-connect + * cold-restore path; + * (c) resume the non-passive record classes (manual sleep of a still-working + * agent, `origin: 'quit'`) with navigation suppressed. + * Woken PTYs auto-publish to mobile via the renderer graph republish, so no + * spawn is awaited. + */ +export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): void { + const worktreeRecords = Object.values( + useAppStore.getState().sleepingAgentSessionsByPaneKey + ).filter((record) => record.worktreeId === worktreeId) + // Why: nothing is slept here, so there is no wake work. Skipping is what keeps + // a phone browsing many worktrees from permanently background-mounting each one + // (and reattaching its PTYs) on the desktop host it is paired to. + if (worktreeRecords.length === 0) { + return + } + + window.dispatchEvent( + new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { + detail: { worktreeId } + }) + ) + // Why: only a passive completed-hibernation record has a not-yet-mounted pane + // that needs a fresh-connect cold-restore (step b). Gating on it avoids mounting + // the worktree for non-passive records — which step (c) recovers into a fresh + // tab — so background-mount can't strand a plain shell in the stale tab. + if (worktreeRecords.some(isPassiveCompletedHibernationEvidence)) { + window.dispatchEvent( + new CustomEvent( + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + { + detail: { worktreeId } + } + ) + ) + } + resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true }) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7d42be8ba46..487c915293a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2344,6 +2344,9 @@ function createWebUiApi(): NonNullable['ui']> { respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is + // desktop-host-scoped, so the web client never receives this signal. + onResumeSleepingAgents: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, // Why: a paired web client has no OS sleep signal; occlusion-driven // visibilitychange already covers its wake recovery. From 33f59ad99edb4305581f064b4dea7e18c7915c1b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 9 Jul 2026 17:47:28 -0400 Subject: [PATCH 04/25] fix(rate-limits): recover WSL Codex usage RPC refresh (#7567) (#7658) * fix(codex): launch WSL usage RPC through interactive shell (#7567) * fix(codex): harden WSL usage RPC shell startup --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/rate-limits/codex-fetcher.test.ts | 97 +++++++++++++++++++--- src/main/rate-limits/codex-fetcher.ts | 21 ++++- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index daaec0ce7fd..77532918702 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -536,13 +536,23 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain( + 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' ) - expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" + ) + expect(shellCommand).toContain( + "exec codex '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''untrusted'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-" + ) + expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -557,6 +567,62 @@ describe('fetchCodexRateLimits', () => { } }) + it('routes Windows host Codex homes through the host RPC path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 13 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('codex') + expect(spawnArgs).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']) + expect(spawnOptions).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { const originalPlatform = process.platform const originalCodexHome = process.env.CODEX_HOME @@ -588,13 +654,20 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).not.toContain('exec 3<&0') + expect(shellCommand).not.toContain('exec /dev/null') + expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) - expect(bashCommand).toContain('exec codex ') + expect(shellCommand).toContain('exec codex ') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 28ef1b710fd..c75be611fe4 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -17,6 +17,10 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd @@ -112,7 +116,8 @@ function shellQuote(value: string): string { function buildWslCodexCommand( codexHomePath: string, - args: string[] + args: string[], + options?: { isolateRpcStdio?: boolean } ): { command: string args: string[] @@ -124,11 +129,19 @@ function buildWslCodexCommand( const script = [ ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}` + `exec codex ${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` ].join(' && ') + const loginShellCommand = buildWslLoginShellCommand(script) + // Why: keep the outer sh non-login and hide RPC pipes before the configured + // shell startup can read input or print banners. + const command = options?.isolateRpcStdio + ? ['exec 3<&0', 'exec 4>&1', 'exec /dev/null', loginShellCommand].join('\n') + : loginShellCommand return { command: 'wsl.exe', - args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + args: ['-d', wslInfo.distro, '--', 'sh', '-c', escapeWslShCommandForWindows(command)] } } @@ -411,7 +424,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise Date: Thu, 9 Jul 2026 14:52:03 -0700 Subject: [PATCH 05/25] Disable experimental new worktree card style by default (#7977) --- src/cli/handlers/agent-hooks.test.ts | 14 ++++++------- src/cli/handlers/agent-hooks.ts | 16 +-------------- src/main/persistence.test.ts | 20 ++++++++++++++++--- src/main/persistence.ts | 30 +--------------------------- 4 files changed, 26 insertions(+), 54 deletions(-) diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 5ed9578685a..4ae4c962d59 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,32 +92,32 @@ describe('agent hooks CLI handler', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('keeps the fresh-profile new card style default when creating offline settings', async () => { + it('keeps new card style off when creating offline settings for a fresh profile', async () => { await runAgentHooksOff(userDataPath) const persisted = readDataFile(userDataPath) - expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(false) expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) - it('defaults missing new card style on while offline-updated onboarding is open', async () => { + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) }) - it('preserves an existing explicit new card style opt-out when updating offline settings', async () => { + it('preserves an existing explicit new card style opt-in when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) - existing.settings.experimentalNewWorktreeCardStyle = false + existing.settings.experimentalNewWorktreeCardStyle = true writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2419be2733f..2b83f5c15ea 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record { function readPersistedState(dataPath: string): PersistedState { if (!existsSync(dataPath)) { - const defaults = getDefaultPersistedState(homedir()) - return { - ...defaults, - settings: { - ...defaults.settings, - // Why: offline CLI can create the first profile before desktop load; - // match the Store fresh-install default instead of pinning old cards. - experimentalNewWorktreeCardStyle: true - } - } + return getDefaultPersistedState(homedir()) } try { const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) @@ -83,14 +74,9 @@ function readEnabledFromDisk(): boolean { function updateEnabledOnDisk(enabled: boolean): string { const dataPath = getDataPath() const state = readPersistedState(dataPath) - const experimentalNewWorktreeCardStyle = - state.settings?.experimentalNewWorktreeCardStyle ?? state.onboarding?.closedAt === null state.settings = { ...getDefaultPersistedState(homedir()).settings, ...state.settings, - // Why: offline CLI can run before Store.load(); mirror its open-onboarding - // default without overriding a saved user opt-out. - experimentalNewWorktreeCardStyle, agentStatusHooksEnabled: enabled } writePersistedState(dataPath, state) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 22f20157d8f..366c9d4256e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -527,7 +527,7 @@ describe('Store', () => { expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) - expect(settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(settings.experimentalNewWorktreeCardStyle).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -658,7 +658,7 @@ describe('Store', () => { expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) - it('defaults new worktree card style on while onboarding is open', async () => { + it('keeps new worktree card style off while onboarding is open', async () => { writeDataFile({ settings: {}, onboarding: { @@ -673,7 +673,7 @@ describe('Store', () => { const store = await createStore() - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { @@ -696,6 +696,20 @@ describe('Store', () => { expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) + it('preserves explicit new worktree card style opt-in on load', async () => { + writeDataFile({ + schemaVersion: 1, + settings: { + experimentalNewWorktreeCardStyle: true + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + }) + it('keeps new worktree card style off for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 67fdd0c8215..0af7920b1a2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1350,10 +1350,6 @@ function resolveSetupGuideSidebarDismissedOnLoad( return onboarding.closedAt !== null || persistedDismissed === true } -function shouldDefaultNewWorktreeCardStyleOn(onboarding: OnboardingState): boolean { - return onboarding.closedAt === null -} - // Why: read a settings field that was removed from GlobalSettings but can // still exist on disk. One-shot use for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { @@ -3067,16 +3063,6 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } - const defaultNewWorktreeCardStyle = - shouldDefaultNewWorktreeCardStyleOn(normalizedOnboarding) - const migratedExperimentalNewWorktreeCardStyle = - parsed.settings?.experimentalNewWorktreeCardStyle ?? defaultNewWorktreeCardStyle - if ( - parsed.settings?.experimentalNewWorktreeCardStyle === undefined && - defaultNewWorktreeCardStyle - ) { - this.loadNeedsSave = true - } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? @@ -3130,9 +3116,6 @@ export class Store { ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // Why: open first-run onboarding is the local fresh-install signal; - // closed/backfilled onboarding identifies existing profiles. - experimentalNewWorktreeCardStyle: migratedExperimentalNewWorktreeCardStyle, // Why: compact worktree cards graduated from Experimental; preserve // the old opt-in for profiles written during the rollout. compactWorktreeCards: loadedCompactWorktreeCards, @@ -3417,18 +3400,7 @@ export class Store { } if (result === null) { - const defaults = getDefaultPersistedState(homedir()) - const isFreshDefaultProfile = - !fileExistedOnLoad && shouldDefaultNewWorktreeCardStyleOn(defaults.onboarding) - result = { - ...defaults, - settings: { - ...defaults.settings, - // Why: a corrupt existing data file also falls back to defaults; only - // the absent-file path is a true fresh install. - experimentalNewWorktreeCardStyle: isFreshDefaultProfile - } - } + result = getDefaultPersistedState(homedir()) } const workspaceSession = pruneWorkspaceSessionBrowserHistory( From 6964bfb23bdad69ca16d5e00b4c68ffb601bd662 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:53:22 +0000 Subject: [PATCH 06/25] release: v1.4.131-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6ad02b2d04..7112a4f39c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.1", + "version": "1.4.131-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 20ed7642113bfc6e63526b6307f7126dadba3ab1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:06 -0700 Subject: [PATCH 07/25] Fix WSL source control path separators (#7966) --- src/main/git/status-wsl-pathspecs.test.ts | 87 +++++++++++++++++++++++ src/main/git/status.ts | 58 ++++++++++----- 2 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 src/main/git/status-wsl-pathspecs.test.ts diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts new file mode 100644 index 00000000000..e802fb72b5b --- /dev/null +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env, + gitStreamStdout: vi.fn() +})) + +import { + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles, + discardChanges, + stageFile, + unstageFile +} from './status' + +const worktreePath = path.resolve('repo') +const windowsRelativePath = 'tests\\breakgit' +const wslOptions = { wslDistro: 'Ubuntu' } + +describe('WSL git pathspecs', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'tests/breakgit\0', stderr: '' }) + }) + + it('uses POSIX separators for tracked file mutations executed inside WSL', async () => { + await stageFile(worktreePath, windowsRelativePath, wslOptions) + await unstageFile(worktreePath, windowsRelativePath, wslOptions) + await bulkStageFiles(worktreePath, [windowsRelativePath], wslOptions) + await bulkUnstageFiles(worktreePath, [windowsRelativePath], wslOptions) + await discardChanges(worktreePath, windowsRelativePath, wslOptions) + await bulkDiscardChanges(worktreePath, [windowsRelativePath], wslOptions) + + const pathspecs = gitExecFileAsyncMock.mock.calls.flatMap(([args]) => + (args as string[]).filter((arg) => arg.startsWith(':(literal)')) + ) + expect(pathspecs).toEqual(Array(8).fill(':(literal)tests/breakgit')) + }) + + it('uses POSIX separators when discarding untracked files inside WSL', async () => { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-wsl-pathspec-')) + const targetPath = path.resolve(repo, windowsRelativePath) + await mkdir(path.dirname(targetPath), { recursive: true }) + await writeFile(targetPath, 'untracked') + + try { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('not tracked')) + .mockResolvedValue({ stdout: '', stderr: '' }) + await discardChanges(repo, windowsRelativePath, wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '--error-unmatch', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await bulkDiscardChanges(repo, [windowsRelativePath], wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + }) + + it('preserves backslashes for host Git where they can be literal filename characters', async () => { + await stageFile(worktreePath, windowsRelativePath) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['add', '--', ':(literal)tests\\breakgit'], { + cwd: worktreePath + }) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 862c8c9b76b..6ec73ef8a3b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1837,7 +1837,7 @@ export async function stageFile( clearGitReadInvalidationState() try { await gitExecFileAsync( - ['add', '--', literalPathspec(filePath)], + ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { @@ -1855,7 +1855,7 @@ export async function unstageFile( ): Promise { clearGitReadInvalidationState() try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { @@ -1961,9 +1961,12 @@ export async function discardChanges( let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) tracked = true } catch { // File is not tracked by git @@ -1971,7 +1974,7 @@ export async function discardChanges( if (tracked) { await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } @@ -1991,9 +1994,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } -function literalPathspec(filePath: string): string { - // Why: source-control selections are concrete paths, not user-authored Git globs. - return `:(literal)${filePath}` +function literalPathspec(filePath: string, options: GitRuntimeOptions): string { + // Why: Windows validation produces backslashes, but Git running inside WSL + // needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal. + const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + return `:(literal)${runtimePath}` } function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { @@ -2013,7 +2018,7 @@ async function listTrackedPathSpecs( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2038,9 +2043,12 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } } @@ -2082,7 +2090,13 @@ export async function bulkDiscardChanges( for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2125,7 +2139,7 @@ export async function bulkStageFiles( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['add', '--', ...chunk.map(literalPathspec)], + ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], gitOptionsForWorktree(worktreePath, options) ) } @@ -2149,9 +2163,17 @@ export async function bulkUnstageFiles( try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + [ + 'restore', + '--staged', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } finally { clearGitReadInvalidationState() From e537953d8f90d57c763fab0ad8f9af57ca2e1521 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:06:30 -0700 Subject: [PATCH 08/25] Fix GitLab auth diagnostic waking WSL (#7967) --- src/main/git/runner-wsl-gh-fallback.test.ts | 23 +++++++++++++++++++++ src/main/git/runner.ts | 2 ++ src/main/gitlab/client-mr.test.ts | 3 +++ src/main/gitlab/client.ts | 5 ++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 3aae182432c..c7710858838 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -352,6 +352,29 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: 'Logged in to gitlab.com', stderr: '' }) + }) + + await expect( + glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) + ).rejects.toThrow('spawn glab ENOENT') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + 'glab', + ['auth', 'status'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('still retries idempotent glab transient failures', async () => { execFileMock .mockImplementationOnce((_binary, _args, _options, callback) => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index c9009845369..fca0db2ef65 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + allowDefaultWslFallback?: boolean } /** @@ -1544,6 +1545,7 @@ export async function glabExecFileAsync( resolved.wsl === null && !options.cwd && !options.wslDistro && + options.allowDefaultWslFallback !== false && isHostCommandMissing(err, 'glab') ) { const wslResolved = resolveDefaultWslCli('glab', args) diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 21747de6a26..a090ec7963b 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => { hosts: ['gitlab.com'], activeHost: 'gitlab.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + allowDefaultWslFallback: false + }) }) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index ec4a178ea64..b6b32dfb8c3 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise { ? 'GLAB_TOKEN' : null try { - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) return { From 73f1bbfc94d0c771a816e964a962bee7f4f85b58 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:56:25 -0700 Subject: [PATCH 09/25] mobile: recover from wedged Tailscale tunnels and say 'check Tailscale' when it's the likely culprit (#7980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wedged Tailscale tunnel (known iOS failure mode) produces no AppState or network-type transition, so no revival nudge ever fires and the reconnect loop parked permanently at its give-up cap — users had to toggle Tailscale off/on just to force a transition (#7824). - rpc-client: past the give-up cap, drop to a 90s trickle dial instead of parking so the session self-heals once the tunnel recovers. - host screen: nudge the shared client on focus so opening the host retries immediately instead of waiting out a backoff/trickle timer. - connection-health: warning/unreachable verdicts on 100.64/10 or *.ts.net endpoints now carry a 'check Tailscale' hint, shown on the home host list and the in-session status line after ~3 failed attempts. - troubleshoot: 'Cannot reach ' now says to check Tailscale, adds a dedicated Tailscale section, and stops telling Tailscale users to disable their VPN (that advice killed their only route to the host); sections extracted to troubleshoot-common-issues.tsx to stay under the max-lines cap. Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 11 +++ .../app/h/[hostId]/session/[worktreeId].tsx | 20 +++-- mobile/app/index.tsx | 7 +- mobile/app/troubleshoot.tsx | 76 ++---------------- .../src/diagnostics/host-reachability.test.ts | 24 +++++- mobile/src/diagnostics/host-reachability.ts | 12 +++ .../troubleshoot-common-issues.tsx | 73 +++++++++++++++++ .../src/transport/connection-health.test.ts | 80 +++++++++++++++++++ mobile/src/transport/connection-health.ts | 40 ++++++++-- mobile/src/transport/rpc-client.test.ts | 32 +++++--- mobile/src/transport/rpc-client.ts | 76 +++++++++++------- 11 files changed, 328 insertions(+), 123 deletions(-) create mode 100644 mobile/src/diagnostics/troubleshoot-common-issues.tsx create mode 100644 mobile/src/transport/connection-health.test.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 16baabe9177..71bda7b64dc 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -552,6 +552,17 @@ export function HostScreen({ } }, [connState, client]) + useFocusEffect( + useCallback(() => { + // Why: opening the host is a strong user signal — reset a backed-off or + // trickling reconnect loop (and probe a possibly half-open socket) + // immediately instead of waiting out its timer. Deps stay empty so this + // fires per focus transition, not per connection-state change; nudging + // on every reconnecting↔connecting flip would defeat the backoff. + clientRef.current?.notifyForeground() + }, []) + ) + useFocusEffect( useCallback(() => { // The embedded sidebar drives its own polling below; focus never fires diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c4701a302db..b556170b4c7 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -62,7 +62,10 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../../src/transport/client-context' -import { classifyConnection } from '../../../../src/transport/connection-health' +import { + classifyConnection, + verdictDisplayLabel +} from '../../../../src/transport/connection-health' import { useResponsiveLayout } from '../../../../src/layout/responsive-layout' import { type ActivePanel, @@ -1000,6 +1003,9 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) + // Why: state (not a ref) — the connection verdict needs a re-render once + // the endpoint loads so the Tailscale hint can appear. + const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every @@ -2453,6 +2459,7 @@ export default function SessionScreen() { const host = hosts.find((h) => h.id === hostId) if (host) { deviceTokenRef.current = host.deviceToken + setHostEndpoint(host.endpoint) } }) return () => { @@ -4265,13 +4272,14 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop parks at its give-up cap; without an in-session - // affordance the only recovery is leaving the screen or restarting the - // app (issue #5049). Surface tap-to-retry once the verdict escalates. + // Why: the reconnect loop slows to a 90s trickle at its give-up cap; + // surface tap-to-retry once the verdict escalates so recovery doesn't + // wait out the trickle timer (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' @@ -4284,7 +4292,7 @@ export default function SessionScreen() { ? '1 tab' : `${visibleTabs.length} tabs` : showConnectionRetry - ? `${connectionVerdict.label} — tap to retry` + ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index acaf97b7646..eaee0e5f935 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -34,7 +34,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection } from '../src/transport/connection-health' +import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -827,7 +827,8 @@ export default function HomeScreen() { const verdict = classifyConnection({ state, reconnectAttempts: attempts, - lastConnectedAt + lastConnectedAt, + endpoint: item.endpoint }) const isError = verdict.kind === 'warning' || @@ -859,7 +860,7 @@ export default function HomeScreen() { - {verdict.label} + {verdictDisplayLabel(verdict)} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 0a1a63b7c27..e9d7a34eef3 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -14,11 +14,6 @@ import { ChevronLeft, ChevronDown, ChevronUp, - WifiOff, - Shield, - Monitor, - Clock, - Globe, Activity, CheckCircle2, XCircle, @@ -30,7 +25,12 @@ import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' -import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' +import { + formatEndpoint, + testHostReachability, + unreachableHostDetail +} from '../src/diagnostics/host-reachability' +import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -40,66 +40,6 @@ type CheckResult = { detail: string } -type TroubleshootSection = { - id: string - icon: React.ReactNode - title: string - steps: string[] -} - -const sections: TroubleshootSection[] = [ - { - id: 'wifi', - icon: , - title: 'Different WiFi Networks', - steps: [ - 'Both devices must be on the same local network.', - 'Ethernet and WiFi must share the same subnet.', - 'Try reconnecting WiFi on both devices.' - ] - }, - { - id: 'firewall', - icon: , - title: 'Firewall Blocking Port 6768', - steps: [ - 'macOS: System Settings → Network → Firewall — allow Orca.', - 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', - 'Linux: sudo ufw allow 6768', - 'Corporate/school networks may block P2P — try a personal hotspot.' - ] - }, - { - id: 'desktop', - icon: , - title: 'Desktop App Not Running', - steps: [ - 'Orca must be open on your desktop to accept connections.', - 'Try restarting Orca — the companion server starts on launch.', - 'After an update, you may need to re-pair via QR code.' - ] - }, - { - id: 'timeout', - icon: , - title: 'Connection Timeout', - steps: [ - 'Check WiFi signal strength on your phone.', - 'Go back to the host list and tap your host to retry.', - 'Restart both apps if timeouts persist.' - ] - }, - { - id: 'vpn', - icon: , - title: 'VPN Interference', - steps: [ - 'VPNs can route local traffic through a remote server.', - 'Disable the VPN or enable split tunneling / "Allow LAN".' - ] - } -] - function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': @@ -211,7 +151,7 @@ export default function TroubleshootScreen() { status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` - : `Cannot reach ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } @@ -295,7 +235,7 @@ export default function TroubleshootScreen() { Common issues - {sections.map((section, i) => ( + {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } { + it('points at Tailscale for tailnet CGNAT endpoints', () => { + expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe( + 'Cannot reach 100.65.9.106:6768 — check Tailscale' + ) + }) + + it('points at Tailscale for MagicDNS endpoints', () => { + expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe( + 'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale' + ) + }) + + it('stays generic for LAN endpoints', () => { + expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768') + }) + + it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { + expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') + }) +}) describe('testHostReachability', () => { it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 7ae292126e8..495d65a9764 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -1,3 +1,5 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' + const HOST_REACHABILITY_TIMEOUT_MS = 4000 // Why: troubleshooting needs a cheap endpoint probe without completing the @@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string { return endpoint } } + +// Why: an unreachable 100.x/*.ts.net host almost always means the phone's +// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by +// toggling the VPN) — point at that instead of a bare "Cannot reach". +export function unreachableHostDetail(endpoint: string): string { + if (isTailscaleEndpoint(endpoint)) { + return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale` + } + return `Cannot reach ${formatEndpoint(endpoint)}` +} diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx new file mode 100644 index 00000000000..5dc91eb65fc --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -0,0 +1,73 @@ +import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +export type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network (unless connected through Tailscale).', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'tailscale', + icon: , + title: 'Tailscale Host Unreachable', + steps: [ + 'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.', + 'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.', + 'Check the desktop is awake and shows as connected in your tailnet.', + 'Update the Tailscale app — recent releases fix reconnect bugs.' + ] + }, + { + id: 'vpn', + icon: , + title: 'Other VPN Interference', + steps: [ + 'Non-Tailscale VPNs can route local traffic through a remote server.', + 'Disable that VPN or enable split tunneling / "Allow LAN".' + ] + } +] diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts new file mode 100644 index 00000000000..22e752a6335 --- /dev/null +++ b/mobile/src/transport/connection-health.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +describe('classifyConnection Tailscale hint', () => { + const base = { + state: 'reconnecting' as const, + lastConnectedAt: null, + nowMs: 1_000_000 + } + + it('adds the hint to the warning verdict for a tailnet CGNAT endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://100.65.9.106:6768' + }) + expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) + }) + + it('adds the hint to the unreachable verdict for a MagicDNS endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://my-desktop.tailnet-1234.ts.net:6768' + }) + expect(verdict).toMatchObject({ + kind: 'unreachable', + reason: 'never-connected', + hint: 'check Tailscale' + }) + }) + + it('keeps plain labels for LAN endpoints', () => { + const warning = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://192.168.1.50:6768' + }) + expect(warning.kind).toBe('warning') + expect('hint' in warning && warning.hint).toBeFalsy() + }) + + it('keeps plain labels when no endpoint is provided', () => { + const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) + expect(verdict.kind).toBe('warning') + expect('hint' in verdict && verdict.hint).toBeFalsy() + }) + + it('never hints on healthy states', () => { + const verdict = classifyConnection({ + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: 999_000, + endpoint: 'ws://100.65.9.106:6768', + nowMs: 1_000_000 + }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) + }) +}) + +describe('verdictDisplayLabel', () => { + it('appends the hint to warning and unreachable labels', () => { + expect( + verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' }) + ).toBe("Can't connect — check Tailscale") + expect( + verdictDisplayLabel({ + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale', + hint: 'check Tailscale' + }) + ).toBe("Can't reach desktop — check Tailscale") + }) + + it('returns the bare label without a hint', () => { + expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect") + expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected') + }) +}) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 4ce81beffd2..d3b8251fc1b 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,3 +1,4 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -11,7 +12,8 @@ import type { ConnectionState } from './types' // reuse the 60s cap). Combined with the never-connected / // stale-since-last-connect heuristic below, this is the trigger to // surface a "re-pair?" affordance. MUST stay aligned with -// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS (past which the loop slows +// to a 90s trickle instead of parking). // - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case @@ -20,10 +22,21 @@ const WARNING_ATTEMPTS = 3 const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000 +// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means +// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode +// that only a manual toggle fixes) — not that the desktop moved. Say so +// instead of leaving the user staring at a generic "Can't connect". +const TAILSCALE_HINT = 'check Tailscale' + export type ConnectionVerdict = | { kind: 'normal'; label: string } - | { kind: 'warning'; label: string } // "Can't connect" - | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'warning'; label: string; hint?: string } // "Can't connect" + | { + kind: 'unreachable' + label: string + reason: 'never-connected' | 'stale' + hint?: string + } | { kind: 'auth-failed'; label: string } // Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have @@ -33,10 +46,14 @@ export function classifyConnection(args: { state: ConnectionState reconnectAttempts: number lastConnectedAt: number | null + // Optional pinned host endpoint — enables the Tailscale hint on + // warning/unreachable verdicts. Callers without it get plain labels. + endpoint?: string | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args const now = args.nowMs ?? Date.now() + const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined if (state === 'auth-failed') { return { kind: 'auth-failed', label: 'Auth failed' } @@ -60,21 +77,32 @@ export function classifyConnection(args: { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'never-connected' + reason: 'never-connected', + hint } } if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'stale' + reason: 'stale', + hint } } } if (reconnectAttempts >= WARNING_ATTEMPTS) { - return { kind: 'warning', label: "Can't connect" } + return { kind: 'warning', label: "Can't connect", hint } } return { kind: 'normal', label: 'Reconnecting…' } } + +// Why: single place that turns a verdict into display text so every screen +// renders the Tailscale hint the same way. +export function verdictDisplayLabel(verdict: ConnectionVerdict): string { + if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) { + return `${verdict.label} — ${verdict.hint}` + } + return verdict.label +} diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e096ee659c9..b2adb18c4ce 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -618,28 +618,42 @@ describe('mobile rpc-client connection timeout', () => { return { client, socket } } - it('repro: a parked reconnect loop never retries on its own', async () => { + // Why 520_000ms: the 12 fast attempts cost Σ(RECONNECT_DELAYS) 360.5s of + // backoff plus 13 × 12s connect timeouts ≈ 516.5s, so 520s lands just + // past the give-up cap with the first trickle timer armed. + const PAST_GIVE_UP_CAP_MS = 520_000 + + it('keeps trickle-retrying after the give-up cap instead of parking', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12) - // Stuck: arbitrary additional time produces no further attempts. + // A wedged VPN produces no revival nudge (issue #7824) — the loop must + // keep dialing on its own at the 90s trickle cadence. const socketsBefore = mockSockets.length - await vi.advanceTimersByTimeAsync(600_000) - expect(mockSockets.length).toBe(socketsBefore) + await vi.advanceTimersByTimeAsync(102_000) + expect(mockSockets.length).toBeGreaterThan(socketsBefore) + + // Once the tunnel heals, a trickle dial restores the session without + // any user action. 75s lands inside the next dial's 12s connect window + // (the prior dial failed mid-advance above, re-arming the 90s timer). + await vi.advanceTimersByTimeAsync(75_000) + openAndAuthenticate(mockSockets[mockSockets.length - 1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) client.close() }) - it('restarts a parked reconnect loop on foreground', async () => { + it('restarts a backed-off reconnect loop on foreground without waiting out the trickle', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getReconnectAttempt()).toBe(12) const socketsBefore = mockSockets.length @@ -921,7 +935,7 @@ describe('mobile rpc-client connection timeout', () => { () => null, (error: Error) => error ) - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(520_000) expect(client.getState()).toBe('reconnecting') expect(client.getReconnectAttempt()).toBe(12) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2cd484de4b8..b9d0c990261 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -103,16 +103,22 @@ export type RpcClient = { // time across all 12 attempts is ≈ 6 minutes before the give-up cap // fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap auto-retry once we're clearly unreachable for a long time. +// Why: cap fast auto-retry once we're clearly unreachable for a long time. // With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before we stop and surface the re-pair banner. The longer +// failure before the UI surfaces the re-pair banner. The longer // runway tolerates flaky AP-isolation routers and laptop sleep cycles // that briefly drop the LAN path. MUST stay aligned with // connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop actually pauses — if these -// drift the user sees "Reconnecting…" while the loop is silently -// parked. +// verdict matches the moment the loop slows to the trickle cadence. const GIVE_UP_AFTER_ATTEMPTS = 12 +// Why: past the cap the loop must never park permanently. A wedged +// Tailscale/VPN tunnel produces no AppState or network-type transition +// (still Wi-Fi, still "online"), so no revival nudge ever fires — users +// had to toggle Tailscale off/on just to force one. A slow trickle dial +// self-heals once the tunnel recovers while staying cheap: one TCP +// attempt per 90s, foreground-only (iOS/Android suspend JS timers in +// the background). +const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. // Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair // while the desktop still listed it as paired with a valid token — a transient @@ -280,9 +286,11 @@ export function connect( if (intentionallyClosed) { return Promise.reject(new Error('Client closed')) } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { - // Why: after the retry cap there is no future state transition to - // release callers waiting before their per-request timeout starts. + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + // Why: past the retry cap the loop only trickles every 90s — callers + // must fail fast rather than hang on a host that's been unreachable + // for minutes. A trickle dial that succeeds flips state to 'connected' + // and later requests go through normally. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { @@ -773,27 +781,35 @@ export function connect( } function scheduleReconnect() { - // Why: spinning reconnect forever drains battery and floods logs + // Why: spinning fast reconnects forever drains battery and floods logs // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a - // "Can't reach desktop, re-pair?" banner at this point and the - // user can tap Retry (forceReconnect creates a fresh client, - // resetting the counter) or Re-pair. Without an explicit cap the - // worst-case is a phone left on the home screen burning a socket - // open every 4s indefinitely. - if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - console.log('[net] reconnect-paused', { - attempt: reconnectAttempt, - reason: 'give-up-cap', - endpoint: redactedEndpoint(endpoint) - }) + // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a + // "Can't reach desktop, re-pair?" banner and the loop drops to the + // 90s trickle cadence instead of parking — a permanently parked loop + // could only be revived by an AppState/network transition, which a + // wedged VPN tunnel never produces. + const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS + let delay: number + if (pastGiveUpCap) { + // Why: the counter holds at the cap — connection-health thresholds and + // the "Can't reach desktop" verdict key off attempts >= 12, and a + // successful open resets it to 0 anyway. + delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') - return + } else { + delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ } - const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) - emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + console.log('[net] scheduleReconnect', { + delayMs: delay, + attempt: reconnectAttempt, + trickle: pastGiveUpCap + }) + emitLog( + 'info', + `Reconnect scheduled in ${delay}ms`, + pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` + ) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -1176,10 +1192,10 @@ export function connect( return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may have parked at the - // give-up cap or be sitting on a 60s backoff timer. Returning to - // the foreground is a strong user signal — restart with a fresh - // attempt budget immediately instead of requiring an app restart. + // Why: while backgrounded the retry loop may be sitting on a 60s + // backoff or 90s trickle timer. Returning to the foreground is a + // strong user signal — restart with a fresh attempt budget + // immediately instead of waiting out the timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer From 69befad13ecc902047bf447067db923e9f76c1eb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:09:09 -0700 Subject: [PATCH 10/25] Harden layout validation, watcher lifecycle, and connection robustness (#7924) * Harden layout validation, watcher lifecycle, and connection robustness - Throw instead of silently skipping when the packaged daemon-entry is missing, preventing layout regressions from passing build checks. - Terminate idle parcel-watcher processes to reclaim native handles and avoid crash-prone native node module teardowns on shutdown. - Bind the persisted WS fallback port first to prevent orphaning active mobile pairings when the preferred port becomes free again. - Cap concurrent disk reads for restored dirty tab verification at three to prevent startup connection bottlenecks on remote SSH workspaces. * Queue file IDs instead of snapshots in restored conflict scans This avoids using stale file snapshots (e.g., outdated disk signatures) if a tab is saved, closed, or re-baselined while waiting in the queue behind the concurrency limit. The live state is now fetched from the store and validated immediately before initiating the disk read. --- config/electron-builder.config.cjs | 11 ++- .../scripts/electron-builder-config.test.mjs | 9 ++ .../scripts/verify-packaged-daemon-entry.cjs | 28 ++++-- .../verify-packaged-daemon-entry.test.mjs | 57 ++++++++++++ package.json | 4 +- src/main/index.ts | 4 +- src/main/ipc/parcel-watcher-process.test.ts | 86 +++++++++++++++++- src/main/ipc/parcel-watcher-process.ts | 26 ++++++ .../runtime/rpc/ws-fallback-port-store.ts | 4 +- src/main/runtime/rpc/ws-transport.test.ts | 71 +++++++++++++++ src/main/runtime/rpc/ws-transport.ts | 62 ++++++------- src/main/runtime/runtime-rpc.ts | 6 +- .../editor-restored-tab-conflict-scan.test.ts | 88 +++++++++++++++++++ .../editor-restored-tab-conflict-scan.ts | 48 +++++++++- 14 files changed, 453 insertions(+), 51 deletions(-) create mode 100644 config/scripts/verify-packaged-daemon-entry.test.mjs diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0148b3cbafd..61f38c081c4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -2,7 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') -const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./scripts/verify-packaged-daemon-entry.cjs') const { createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, @@ -141,8 +144,12 @@ module.exports = { if (context.arch === hostArchEnum || context.arch === 4) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { + // Why: a cross-arch slice can't be booted by the host Node, but the + // unpacked entry must still exist — its absence is a layout regression + // regardless of arch, so only the boot is skipped, not the check. + assertPackagedDaemonEntryExists(resourcesDir) console.log( - `[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})` + `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) } chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 850b8810f9b..cd46c8003fa 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -353,6 +353,15 @@ describe('electron-builder config', () => { const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 6ad91285ca3..8f5e802825c 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -2,6 +2,24 @@ const { existsSync } = require('node:fs') const { spawnSync } = require('node:child_process') const { join } = require('node:path') +// Why: `asarUnpack` in config/electron-builder.config.cjs lists +// out/main/daemon-entry.js on every platform, and the packaged daemon fork +// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A +// missing entry means the package layout regressed, so the check throws +// instead of skipping — a silent skip false-passed exactly the layout bug +// this gate exists to catch. +function assertPackagedDaemonEntryExists(resourcesDir) { + const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + if (!existsSync(entryPath)) { + throw new Error( + `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` + + `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` + + `daemon cannot be forked from this layout` + ) + } + return entryPath +} + // Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron // `require` leaked into its bundle) while every build check passed. This boots // the PACKAGED daemon-entry under plain Node against the asar-unpacked layout, @@ -14,13 +32,7 @@ const { join } = require('node:path') // /resources elsewhere). execPath defaults to the packaging Node. function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { const execPath = options.execPath || process.execPath - const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') - if (!existsSync(entryPath)) { - // Why: some targets/layouts do not unpack here; skip rather than fail so - // the hook stays safe across platforms it has not verified. - console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`) - return - } + const entryPath = assertPackagedDaemonEntryExists(resourcesDir) const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 }) if (result.error) { @@ -43,4 +55,4 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') } -module.exports = { verifyPackagedDaemonEntryBoots } +module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs new file mode 100644 index 00000000000..8705953ba61 --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./verify-packaged-daemon-entry.cjs') + +describe('verify-packaged-daemon-entry', () => { + let resourcesDir + + beforeEach(() => { + resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-')) + }) + + afterEach(() => { + rmSync(resourcesDir, { recursive: true, force: true }) + }) + + function writePackagedEntry(source) { + const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + mkdirSync(entryDir, { recursive: true }) + writeFileSync(join(entryDir, 'daemon-entry.js'), source) + } + + // Why: a silent skip on a missing entry false-passed exactly the packaged + // layout regression this gate exists to catch (rc.1 daemon-load incident). + it('throws when the unpacked daemon entry is missing', () => { + expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + }) + + it('passes when the packaged entry loads and reaches argv parsing', () => { + writePackagedEntry('console.error("Usage: daemon-entry "); process.exit(1)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow() + }) + + it('fails when the packaged entry cannot resolve its module graph', () => { + writePackagedEntry('require("orca-module-that-does-not-exist")\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /failed to load under plain Node/ + ) + }) + + it('fails when the packaged entry never reaches argv parsing', () => { + writePackagedEntry('process.exit(0)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/) + }) +}) diff --git a/package.json b/package.json index 7112a4f39c7..a24b1d1d669 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/main/index.ts b/src/main/index.ts index 9e80bcc2944..8c2a5a4e160 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2047,7 +2047,9 @@ app.whenReady().then(async () => { // a random OS-assigned port — breaking deterministic mobile pairing/repro // scripts against the dev instance. Pin the first dev instance to 6769 so // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via - // ws-transport's EADDRINUSE handler. + // ws-transport's EADDRINUSE handler. Note: once an instance has ever fallen + // back, the persisted fallback port is re-bound in preference to 6769 + // (STA-1511) until mobile-ws-fallback-port.json is removed from userData. const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null try { diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index cb5caf2eaec..4df3b666fbb 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -103,8 +103,13 @@ describe('subscribeViaWatcherProcess', () => { it('resolves unsubscribe on the child ack', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - const id = ackSubscribe(child) + const id = ackSubscribe(child, 0) const subscription = await promise + // Why: hold a second subscription so this unsubscribe exercises the ack + // path instead of the last-subscriber idle kill. + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) @@ -115,8 +120,11 @@ describe('subscribeViaWatcherProcess', () => { it('resolves a pending unsubscribe when the child dies', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - ackSubscribe(child) + ackSubscribe(child, 0) const subscription = await promise + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() child.connected = false @@ -187,6 +195,80 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + const subscription = await promise + + await expect(subscription.unsubscribe()).resolves.toBeUndefined() + expect(first.kill).toHaveBeenCalledTimes(1) + + // Why: the deliberate idle kill must not count as a crash — no respawn + // and no crash-fuse advance when the killed child's exit event lands. + first.connected = false + first.emit('exit', null, 'SIGTERM') + expect(forkMock).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('kills the idle child when the last pending subscribe fails', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const first = currentChild() + first.emit('message', { + op: 'subscribe-failed', + id: first.sent[0].id, + message: 'Error opening directory' + }) + await expect(promise).rejects.toThrow('Error opening directory') + expect(first.kill).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + ackSubscribe(currentChild()) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('keeps the child alive when a subscribe fails while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await firstPromise + + const failingPromise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + child.emit('message', { op: 'subscribe-failed', id: child.sent.at(-1)!.id, message: 'boom' }) + await expect(failingPromise).rejects.toThrow('boom') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('keeps the child alive while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const secondPromise = subscribeViaWatcherProcess('/b', vi.fn(), {}) + ackSubscribe(child) + const second = await secondPromise + + const firstUnsub = first.unsubscribe() + expect(child.kill).not.toHaveBeenCalled() + expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) + + // The last unsubscribe kills the idle child, which also completes the + // still-pending unsubscribe ack from the first record. + const secondUnsub = second.unsubscribe() + expect(child.kill).toHaveBeenCalledTimes(1) + await expect(firstUnsub).resolves.toBeUndefined() + await expect(secondUnsub).resolves.toBeUndefined() + }) + it('uses the in-process watcher under vitest', async () => { vi.stubEnv('VITEST', 'true') const unsubscribe = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 25e52794a8d..58ad6097011 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -183,6 +183,9 @@ function handleChildMessage(message: WatcherToHostMessage): void { } else { record.callback(new Error(message.message), []) } + // Why: a failed last subscribe empties the records map without any + // unsubscribe ever being called — tear down the idle child here too. + killWatcherChildIfIdle() return } if (message.op === 'events') { @@ -243,6 +246,25 @@ function handleChildGone(proc: ChildProcess): void { } } +// Why: the last record gone leaves the child idle until app shutdown. Killing +// it releases native handles without running watcher.node's crash-prone async +// teardown (same rationale as disposeWatcherProcess) and reclaims the process; +// the next subscribe forks a fresh child. `child = null` before kill so the +// exit event neither counts as a crash nor respawns. Process death also +// completes any still-pending unsubscribe acks. +function killWatcherChildIfIdle(): void { + const proc = child + if (!proc || records.size > 0) { + return + } + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + proc.kill() +} + function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { return { unsubscribe: (): Promise => { @@ -253,6 +275,10 @@ function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscriptio if (!proc?.connected) { return Promise.resolve() } + if (records.size === 0) { + killWatcherChildIfIdle() + return Promise.resolve() + } return new Promise((resolve) => { pendingUnsubscribes.set(record.id, resolve) sendToChild(proc, { op: 'unsubscribe', id: record.id }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index 93871cad631..beedcea8cf2 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -5,7 +5,9 @@ import { join } from 'node:path' // assigns a random port. Paired mobile devices store ws://ip:port endpoints, // so a port that changes on every restart permanently orphans those pairings // (STA-1511). Persist the assigned fallback so the same instance re-binds the -// same port next launch. +// same port next launch — the transport binds a persisted fallback BEFORE the +// preferred port, so pairings survive even when the preferred port is free +// again. const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index a44469d1fcc..391e5e18284 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -454,6 +454,77 @@ describe('WebSocketTransport', () => { return port } + it('binds the persisted fallback port even when the preferred port is free', async () => { + // Why: regression for the STA-1511 follow-up — devices paired while the + // fallback port was active store ws://ip:. A later launch that + // finds the preferred port free must still bind the fallback, or those + // pairings go permanently dead until the user re-pairs. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + + it('binds the preferred port when the persisted fallback is taken', async () => { + const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(fallbackHolder) + await fallbackHolder.start() + const takenFallbackPort = fallbackHolder.resolvedPort + const preferredPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort: takenFallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls through to the preferred port when the fallback bind fails with a non-EADDRINUSE error', async () => { + // Why: a persisted fallback can land in an OS-reserved range on a later + // launch (Windows Hyper-V excluded ports → EACCES). That must degrade to + // the preferred port instead of disabling the transport for the session. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + const realTryListen = withListen.tryListen.bind(transport) + withListen.tryListen = (port: number) => + port === fallbackPort + ? Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + : realTryListen(port) + + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: await reserveFreePort() + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + withListen.tryListen = () => + Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + + await expect(transport.start()).rejects.toThrow('listen EACCES') + }) + it('retries the persisted fallback port before an OS-assigned one', async () => { const holder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(holder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 7b1d8b8ead0..8b88c7a820e 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -48,10 +48,11 @@ export type WebSocketTransportOptions = { // Why: the pairing server can also serve the browser client, so users do // not need a second dev/static server once the web bundle is built. staticRoot?: string - // Why: when the preferred port is taken, an unstable OS-assigned port would - // change on every restart and permanently orphan existing mobile pairings - // (their stored ws://ip:port endpoint goes dead — STA-1511). Callers pass - // the previously assigned fallback port so it is retried first. + // Why: paired mobile devices store the full ws://ip:port endpoint. Once a + // fallback port has been assigned and persisted, devices paired while it was + // active point at it, so it must be bound FIRST on later launches — binding + // the (now free) preferred port instead would strand those pairings + // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number } @@ -149,39 +150,40 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: when the preferred port is occupied (e.g. another Orca instance is - // already running), fall back to an OS-assigned port so mobile pairing - // still works. The QR code reads resolvedPort after start, so it will - // advertise the correct port regardless. A persisted fallback port is - // retried before port 0 so paired devices keep a stable endpoint across - // restarts (STA-1511). - let port = this.port - try { - await this.tryListen(port) - return - } catch (error: unknown) { - if (!isEAddressInUse(error) || port === 0) { - throw error - } - } - if ( - this.fallbackPort !== undefined && - this.fallbackPort !== 0 && - this.fallbackPort !== this.port - ) { + // Why: a persisted fallback port is bound FIRST — devices paired while it + // was active store ws://ip: and would be permanently stranded if + // a later launch grabbed the (now free) preferred port instead (STA-1511). + // Without a persisted fallback the preferred port is tried first. On + // EADDRINUSE each candidate falls through to the next, ending at port 0 + // (OS-assigned) so mobile pairing still works when everything is taken. + // The QR code reads resolvedPort after start, so it always advertises the + // port that actually bound. + const persistedFallbackPort = + this.fallbackPort !== undefined && this.fallbackPort !== 0 && this.fallbackPort !== this.port + ? this.fallbackPort + : undefined + const candidatePorts = + persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + for (const port of candidatePorts) { try { - console.warn( - `[ws-transport] Port ${port} is in use, retrying previous fallback port ${this.fallbackPort}` - ) - await this.tryListen(this.fallbackPort) + await this.tryListen(port) return } catch (error: unknown) { - if (!isEAddressInUse(error)) { + // Why: a persisted fallback can become unbindable for reasons beyond + // EADDRINUSE (e.g. Windows reserves dynamic-range ports for Hyper-V + // after a reboot → EACCES). Any fallback failure must degrade to the + // next candidate — aborting would disable the transport every launch + // while the store still names that port. Only preferred-port failures + // other than EADDRINUSE are fatal. + if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { throw error } + console.warn( + `[ws-transport] Failed to bind port ${port} (${error instanceof Error ? error.message : String(error)}), trying next candidate` + ) } } - console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + console.warn('[ws-transport] All configured ports failed to bind, using an OS-assigned port') await this.tryListen(0) } diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8e05aaeec02..f8b3c75c7cf 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -705,8 +705,10 @@ export class OrcaRuntimeRpcServer { port: this.wsPort, staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired - // devices' stored endpoints stay valid (STA-1511). wsPort 0 means - // the caller explicitly wants a random port (E2E) — don't pin it. + // devices' stored endpoints stay valid (STA-1511) — the transport + // binds a persisted fallback before the preferred port. wsPort 0 + // means the caller explicitly wants a random port (E2E) — don't + // pin it. ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) this.wsTransport = wsTransport diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8c50887a090..b0a3816688a 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -250,6 +250,43 @@ describe('attachRestoredTabConflictScan', () => { } }) + it('caps concurrent verification reads and drains the queue without dropping tabs', async () => { + // Why: a restored session with many dirty tabs must not fire one disk + // read per tab at once — on SSH/remote runtimes that competes with + // connection recovery. The cap is 3; the rest queue and all complete. + const pendingReads: ((value: { content: string; isBinary: boolean }) => void)[] = [] + mocks.readRuntimeFileContent.mockImplementation( + () => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pendingReads.push(resolve) + }) + ) + const store = createEditorStore() + for (let i = 0; i < 6; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'original baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(4) + + while (pendingReads.length > 0) { + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(6) + for (const file of store.getState().openFiles) { + expect(file.pendingDiskBaselineVerification).toBeUndefined() + } + } finally { + detach() + } + }) + it('does not mark a tab that was saved while the read was in flight', async () => { let resolveRead: (value: { content: string; isBinary: boolean }) => void = () => {} mocks.readRuntimeFileContent.mockReturnValue( @@ -270,4 +307,55 @@ describe('attachRestoredTabConflictScan', () => { detach() } }) + + it('verifies a queued tab against its live baseline, not the stale queued snapshot', async () => { + // Why: ids are paths, so a tab re-baselined (saved / reopened) while it + // waits behind the concurrency cap must be compared against its current + // baseline. Queuing the OpenFile snapshot would mark the live tab using the + // old lastKnownDiskSignature. + const pending: { + filePath: string + resolve: (value: { content: string; isBinary: boolean }) => void + }[] = [] + mocks.readRuntimeFileContent.mockImplementation( + ({ filePath }: { filePath: string }) => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pending.push({ filePath, resolve }) + }) + ) + const store = createEditorStore() + // Three reads fill the concurrency cap; the fourth waits in the queue. + for (let i = 0; i < 4; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'stale baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + // The queued tab is re-baselined to match current disk while it waits. + store + .getState() + .setLastKnownDiskSignature('/repo/file-3.ts', getDiskBaselineSignature('current disk')) + + // Drain the in-flight reads; each frees a slot so the queued tab dispatches. + for (const filePath of ['/repo/file-0.ts', '/repo/file-1.ts', '/repo/file-2.ts']) { + pending + .find((p) => p.filePath === filePath)! + .resolve({ content: 'stale baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + + pending + .find((p) => p.filePath === '/repo/file-3.ts')! + .resolve({ content: 'current disk', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + + const tab = store.getState().openFiles.find((f) => f.id === '/repo/file-3.ts') + expect(tab?.externalMutation).toBeUndefined() + expect(tab?.pendingDiskBaselineVerification).toBeUndefined() + } finally { + detach() + } + }) }) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index bc4029321f4..26d7e4db0ca 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -29,13 +29,23 @@ type AppStoreApi = Pick, 'getState' | 'subscribe'> const VERIFY_RETRY_MS = 2_000 const VERIFY_SLOW_RETRY_MS = 15_000 const VERIFY_FAST_ATTEMPTS = 30 +// Why: a session restored with many dirty tabs would otherwise fire one disk +// read per tab simultaneously — on SSH/remote runtimes that's N concurrent +// 15s-timeout RPCs competing with connection recovery at startup. Verifies +// beyond the cap queue and start as slots free up; none are dropped. +const MAX_CONCURRENT_VERIFY_READS = 3 export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { - // Why: dedupes in-flight verifications; the store's pending flag is the - // durable "needs verification" signal. + // Why: dedupes queued + in-flight verifications; the store's pending flag is + // the durable "needs verification" signal. const inFlightFileIds = new Set() const attemptsByFileId = new Map() const retryTimers = new Set>() + // Why: queue ids, not OpenFile snapshots. Ids are paths, so a snapshot can go + // stale (close/reopen or save at the same path) while it waits for a slot; + // the live file is re-read at dispatch instead. + const verifyQueue: string[] = [] + let activeVerifyReads = 0 let disposed = false // Why: distinguishes "file was deleted while the app was closed" (a @@ -144,6 +154,36 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } } + const pumpVerifyQueue = (): void => { + while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) { + const fileId = verifyQueue.shift()! + // Why: re-read the live file when the slot opens. A queued id may now + // resolve to a reopened or saved same-path tab, so re-validate it against + // the current state before spending a disk read; skipping frees the dedupe + // marker so a later scan can re-queue it if it needs verification again. + const file = store.getState().openFiles.find((f) => f.id === fileId) + if ( + !file || + !file.pendingDiskBaselineVerification || + !file.isDirty || + !file.lastKnownDiskSignature || + file.externalMutation === 'changed' || + !canAutoSaveOpenFile(file) + ) { + inFlightFileIds.delete(fileId) + continue + } + activeVerifyReads += 1 + const onSettled = (): void => { + activeVerifyReads -= 1 + pumpVerifyQueue() + } + // Why: verify() never rejects by design, but a rejection here must still + // release the slot or the queue stalls permanently. + void verify(file).then(onSettled, onSettled) + } + } + const scan = (): void => { if (disposed) { return @@ -160,8 +200,9 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { continue } inFlightFileIds.add(file.id) - void verify(file) + verifyQueue.push(file.id) } + pumpVerifyQueue() } let previousOpenFiles = store.getState().openFiles @@ -182,5 +223,6 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { clearTimeout(timer) } retryTimers.clear() + verifyQueue.length = 0 } } From 9c111fd7aa8f5bd270b5e66313cbf3747d71e8e7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:20:07 -0700 Subject: [PATCH 11/25] mobile: per-host connection log screen with copy-diagnostics (#7984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rpc-client has always emitted a detailed connection lifecycle log (dials, timeouts, close codes, handshake steps, retries) via onLog, but only the pairing screen wired it up — for long-lived host connections everything went to console.log, invisible to users. Debugging reports like #7824/#6928 meant asking reporters for facts the app already knew. - connection-log-buffer: bounded (200/host) module-level ring buffer with referentially-stable snapshots for useSyncExternalStore; survives client swaps and provider remounts. - client-context: wire onLog for every shared host client. - connection-log screen: live per-host log (reuses the pairing ConnectionLog component), host picker, and a Copy Diagnostics button that bundles app/platform versions, endpoint (flagged if Tailscale), state, attempt count, last-connected, and the event log into one shareable blob. - troubleshoot: 'View connection log' entry point. Co-authored-by: Orca --- mobile/app/_layout.tsx | 1 + mobile/app/connection-log.tsx | 221 ++++++++++++++++++ mobile/app/troubleshoot.tsx | 12 + .../connection-diagnostics-report.test.ts | 55 +++++ .../connection-diagnostics-report.ts | 58 +++++ mobile/src/transport/client-context.tsx | 8 +- .../transport/connection-log-buffer.test.ts | 56 +++++ mobile/src/transport/connection-log-buffer.ts | 84 +++++++ 8 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 mobile/app/connection-log.tsx create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.test.ts create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.ts create mode 100644 mobile/src/transport/connection-log-buffer.test.ts create mode 100644 mobile/src/transport/connection-log-buffer.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f47ec591057..d1c5ebff14a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -177,6 +177,7 @@ export default function RootLayout() { + diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx new file mode 100644 index 00000000000..cc19497e080 --- /dev/null +++ b/mobile/app/connection-log.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import * as Clipboard from 'expo-clipboard' +import Constants from 'expo-constants' +import { ChevronLeft, Copy, Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { ConnectionLog } from '../src/components/ConnectionLog' +import { loadHosts } from '../src/transport/host-store' +import { connectionLogStore } from '../src/transport/connection-log-buffer' +import { + useHostClient, + useLastConnectedAt, + useReconnectAttempt +} from '../src/transport/client-context' +import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' +import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' + +// Why: getSnapshot must be referentially stable when there's no data — +// a fresh [] per call would make useSyncExternalStore re-render forever. +const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] + +// Why: reading the log is most needed while a host is failing, so this +// screen also *acquires* the host client — opening it kicks a dial and the +// log fills live instead of showing a stale tail. +export default function ConnectionLogScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [hosts, setHosts] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + let stale = false + void loadHosts().then((loaded) => { + if (stale) { + return + } + setHosts(loaded) + setSelectedId((prev) => prev ?? loaded[0]?.id ?? null) + }) + return () => { + stale = true + } + }, []) + + const selected = hosts.find((h) => h.id === selectedId) ?? null + const { state } = useHostClient(selected?.id) + const reconnectAttempts = useReconnectAttempt(selected?.id) + const lastConnectedAt = useLastConnectedAt(selected?.id) + + const subscribe = useCallback( + (listener: () => void) => + selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {}, + [selectedId] + ) + const getSnapshot = useCallback( + () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES), + [selectedId] + ) + const entries = useSyncExternalStore(subscribe, getSnapshot) + + const copyDiagnostics = useCallback(async () => { + if (!selected) { + return + } + const report = buildConnectionDiagnosticsReport({ + hostName: selected.name, + endpoint: selected.endpoint, + state, + reconnectAttempts, + lastConnectedAt, + platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), + appVersion: Constants.expoConfig?.version ?? 'unknown', + entries + }) + await Clipboard.setStringAsync(report) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [selected, state, reconnectAttempts, lastConnectedAt, entries]) + + return ( + + + router.back()}> + + + Connection log + + + {hosts.length > 1 && ( + + {hosts.map((host) => ( + setSelectedId(host.id)} + > + + {host.name} + + + ))} + + )} + + {selected ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy diagnostics'} + + + {entries.length > 0 ? ( + + ) : ( + + No connection events yet this session. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + hostPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.md + }, + hostChip: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 16, + backgroundColor: colors.bgRaised + }, + hostChipActive: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostChipText: { + fontSize: typography.metaSize, + color: colors.textSecondary, + maxWidth: 160 + }, + hostChipTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + statusText: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + copyButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + emptyText: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index e9d7a34eef3..5ae53e0084c 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -16,6 +16,7 @@ import { ChevronUp, Activity, CheckCircle2, + ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' @@ -213,6 +214,17 @@ export default function TroubleshootScreen() { + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => router.push('/connection-log')} + > + + View connection log + + {checks.length > 0 && ( {checks.map((check, i) => ( diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts new file mode 100644 index 00000000000..d2326ffa022 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' + +const NOW = Date.UTC(2026, 6, 9, 22, 0, 0) + +describe('buildConnectionDiagnosticsReport', () => { + it('summarizes a failing Tailscale host with its log', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 1', + endpoint: 'ws://100.65.9.106:6768', + state: 'reconnecting', + reconnectAttempts: 12, + lastConnectedAt: NOW - 5 * 60_000, + platform: 'ios 26.5.1', + appVersion: '0.0.29', + entries: [ + { + id: 'log-1', + ts: NOW - 60_000, + level: 'error', + message: 'WebSocket connect timeout', + detail: 'No TCP/WS handshake within 12s — endpoint unreachable?' + } + ], + nowMs: NOW + }) + + expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1') + expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)') + expect(report).toContain('State: reconnecting (reconnect attempts: 12)') + expect(report).toContain('(5m 0s ago)') + expect(report).toContain( + '[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?' + ) + }) + + it('marks never-connected sessions and empty logs', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 2', + endpoint: 'ws://192.168.1.50:6768', + state: 'connecting', + reconnectAttempts: 0, + lastConnectedAt: null, + platform: 'android 15', + appVersion: '0.0.29', + entries: [], + nowMs: NOW + }) + + expect(report).toContain('Endpoint: 192.168.1.50:6768') + expect(report).not.toContain('(Tailscale)') + expect(report).toContain('Last connected: never this session') + expect(report).toContain('No connection events recorded this session.') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts new file mode 100644 index 00000000000..c762c69799e --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -0,0 +1,58 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import { formatEndpoint } from './host-reachability' + +// Why: one shareable text blob answering everything we historically had to +// ask reporters one message at a time (endpoint type, state, attempt count, +// last-connected, versions, and the reconnect lifecycle log). +export function buildConnectionDiagnosticsReport(args: { + hostName: string + endpoint: string + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + platform: string + appVersion: string + entries: readonly ConnectionLogEntry[] + nowMs?: number +}): string { + const now = args.nowMs ?? Date.now() + const lines: string[] = [] + lines.push('Orca Mobile connection diagnostics') + lines.push(`Generated: ${new Date(now).toISOString()}`) + lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`) + lines.push(`Host: ${args.hostName}`) + lines.push( + `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}` + ) + lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`) + lines.push( + args.lastConnectedAt == null + ? 'Last connected: never this session' + : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)` + ) + lines.push('') + if (args.entries.length === 0) { + lines.push('No connection events recorded this session.') + } else { + lines.push(`Connection log (${args.entries.length} events, oldest first):`) + for (const entry of args.entries) { + const detail = entry.detail ? ` — ${entry.detail}` : '' + lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) + } + } + return lines.join('\n') +} + +function formatAgo(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s` + } + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 2b8461c582f..41f1b5092e8 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -20,6 +20,7 @@ import { type ReactNode } from 'react' import { connect, type RpcClient } from './rpc-client' +import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -150,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { let client: RpcClient try { - client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, { + // Why: retain reconnect lifecycle events for the Connection Log + // screen — without this the reasons a host is stuck live only in + // console.log, which users can't see or share. + onLog: (entry) => connectionLogStore.append(hostId, entry) + }) } catch { // Why: connect() can throw synchronously if the public key is // malformed or the endpoint URL is invalid. Notify so the UI diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts new file mode 100644 index 00000000000..e7ce662534b --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { createConnectionLogStore } from './connection-log-buffer' +import type { ConnectionLogEntry } from './types' + +function entry(id: number): ConnectionLogEntry { + return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` } +} + +describe('connection log buffer', () => { + it('keeps entries per host without cross-talk', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1']) + expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2']) + }) + + it('drops the oldest entries past the cap', () => { + const store = createConnectionLogStore(3) + for (let i = 1; i <= 5; i++) { + store.append('host-a', entry(i)) + } + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5']) + }) + + it('returns a stable snapshot reference until the next append', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + + const first = store.get('host-a') + expect(store.get('host-a')).toBe(first) + + store.append('host-a', entry(2)) + expect(store.get('host-a')).not.toBe(first) + // Empty hosts must also be referentially stable (useSyncExternalStore). + expect(store.get('host-b')).toBe(store.get('host-b')) + }) + + it('notifies only the host being appended to and stops after unsubscribe', () => { + const store = createConnectionLogStore() + const onA = vi.fn() + const onB = vi.fn() + const unsubA = store.subscribe('host-a', onA) + store.subscribe('host-b', onB) + + store.append('host-a', entry(1)) + expect(onA).toHaveBeenCalledTimes(1) + expect(onB).not.toHaveBeenCalled() + + unsubA() + store.append('host-a', entry(2)) + expect(onA).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts new file mode 100644 index 00000000000..1b223ad8e1f --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.ts @@ -0,0 +1,84 @@ +import type { ConnectionLogEntry } from './types' + +// Why: the rpc-client's onLog entries were only wired during pairing; for +// long-lived host connections everything went to console.log, invisible to +// users. This buffer retains the recent lifecycle events per host so a +// "Connection log" screen (and copy-diagnostics) can show why a connection +// is stuck without a debug build. Module-level so the log survives client +// swaps (forceReconnect) and provider remounts (hot reload); bounded so an +// all-night reconnect loop can't grow memory unbounded. +const MAX_ENTRIES_PER_HOST = 200 + +export type ConnectionLogStore = { + append: (hostId: string, entry: ConnectionLogEntry) => void + get: (hostId: string) => readonly ConnectionLogEntry[] + subscribe: (hostId: string, listener: () => void) => () => void +} + +export function createConnectionLogStore( + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST +): ConnectionLogStore { + const entriesByHost = new Map() + const listenersByHost = new Map void>>() + // Why: useSyncExternalStore compares snapshots by reference — getSnapshot + // must return the SAME array until the data actually changes, or React + // loops re-rendering. Cache per host; invalidate on append. + const snapshotByHost = new Map() + const EMPTY: readonly ConnectionLogEntry[] = [] + + return { + append(hostId, entry) { + let entries = entriesByHost.get(hostId) + if (!entries) { + entries = [] + entriesByHost.set(hostId, entries) + } + entries.push(entry) + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) + } + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + }, + + get(hostId) { + const cached = snapshotByHost.get(hostId) + if (cached) { + return cached + } + const entries = entriesByHost.get(hostId) + if (!entries || entries.length === 0) { + return EMPTY + } + const snapshot = Object.freeze([...entries]) + snapshotByHost.set(hostId, snapshot) + return snapshot + }, + + subscribe(hostId, listener) { + let listeners = listenersByHost.get(hostId) + if (!listeners) { + listeners = new Set() + listenersByHost.set(hostId, listeners) + } + listeners.add(listener) + return () => { + const set = listenersByHost.get(hostId) + if (!set) { + return + } + set.delete(listener) + if (set.size === 0) { + listenersByHost.delete(hostId) + } + } + } + } +} + +export const connectionLogStore = createConnectionLogStore() From e7ee15f4b24b62a540cb8478dd590c39b5f9a34a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:24:20 -0700 Subject: [PATCH 12/25] Improve workspace cleanup list (#7053) * Improve workspace cleanup list Co-authored-by: Orca * Address workspace cleanup review feedback Co-authored-by: Orca * Fix workspace cleanup perf findings Co-authored-by: Orca * Avoid stale cleanup progress cache Co-authored-by: Orca * Complete workspace cleanup perf fixes Co-authored-by: Orca * Fix worktree list option forwarding Co-authored-by: Orca * Address workspace cleanup review nits Co-authored-by: Orca * Fix workspace cleanup removal review findings - Fail a queued removal that now needs a force the user never approved (confirm-time approvedCandidates snapshot compared in preflight) - Reword the 120s removal timeout to say removal continues in background - Wire suppressPreservedBranchToast into cleanup removals - Stop statting a repo after the first activity metadata timeout - Document the WSL 9P best-effort stat gap; drop unused locale key Co-authored-by: Orca * Split workspace-cleanup slice test to satisfy max-lines Rebasing onto latest main pushed the combined store-slice test over the 800-line cap. Extract shared fixtures into a test harness and split the suite into scan-progress and removal-preflight files instead of adding a forbidden max-lines suppression. --------- Co-authored-by: Orca Co-authored-by: Brennan Benson --- .../ipc/workspace-cleanup-activity.test.ts | 163 ++++ src/main/ipc/workspace-cleanup-activity.ts | 126 +++ src/main/ipc/workspace-cleanup-scan.ts | 97 +- src/main/ipc/workspace-cleanup.test.ts | 232 ++++- src/main/repo-worktrees.test.ts | 22 +- src/main/repo-worktrees.ts | 8 +- src/relay/git-handler.test.ts | 24 +- src/relay/git-handler.ts | 12 +- .../src/components/sidebar/WorktreeCard.tsx | 10 +- .../WorkspaceCleanupDialog.tsx | 832 +++++++----------- ...rkspace-cleanup-background-removal.test.ts | 389 ++++++++ .../workspace-cleanup-background-removal.ts | 216 +++++ .../workspace-cleanup-candidate-labels.ts | 176 ++++ ...rkspace-cleanup-candidate-row-data.test.ts | 59 ++ .../workspace-cleanup-candidate-row-data.ts | 204 +++++ ...orkspace-cleanup-candidate-row-details.tsx | 107 +++ .../workspace-cleanup-candidate-row.test.tsx | 65 ++ .../workspace-cleanup-candidate-row.tsx | 342 +++++++ ...rkspace-cleanup-removal-candidates.test.ts | 16 + .../workspace-cleanup-removal-candidates.ts | 13 + .../workspace-cleanup-status-pill.tsx | 26 + src/renderer/src/i18n/locales/en.json | 62 +- src/renderer/src/i18n/locales/es.json | 62 +- src/renderer/src/i18n/locales/ja.json | 62 +- src/renderer/src/i18n/locales/ko.json | 62 +- src/renderer/src/i18n/locales/zh.json | 62 +- .../src/store/slices/store-cascades.test.ts | 69 ++ ...orkspace-cleanup-removal-preflight.test.ts | 431 +++++++++ .../workspace-cleanup-scan-progress.test.ts | 598 +++++++++++++ .../workspace-cleanup-slice-test-harness.ts | 110 +++ .../store/slices/workspace-cleanup.test.ts | 726 --------------- .../src/store/slices/workspace-cleanup.ts | 203 ++++- .../src/store/slices/worktree-helpers.ts | 4 +- src/renderer/src/store/slices/worktrees.ts | 28 +- 34 files changed, 4263 insertions(+), 1355 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup-activity.test.ts create mode 100644 src/main/ipc/workspace-cleanup-activity.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx create mode 100644 src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup.test.ts diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts new file mode 100644 index 00000000000..72f20291f4a --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -0,0 +1,163 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../shared/types' +import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' + +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo-feature', + repoId: 'repo-1', + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + ...overrides + } +} + +describe('resolveWorkspaceCleanupActivityWorktree', () => { + it('uses local worktree filesystem metadata when persisted activity is missing', async () => { + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000 + })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath) + + expect(statPath).toHaveBeenCalledWith('/repo-feature') + expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(worktree.lastActivityAt).toBe(20_000) + }) + + it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { + const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + })) + const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(40_000) + }) + + it('converts WSL linked worktree gitdir pointers before reading metadata', async () => { + const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` + const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + [worktreePath]: 10_000, + [path.join(worktreePath, '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('keeps persisted activity when it is newer than local metadata', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ lastActivityAt: 30_000 }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(30_000) + }) + + it('does not stat remote worktree paths', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + { ...REPO, connectionId: 'ssh-1' }, + makeWorktree({ createdAt: 10_000 }), + statPath + ) + + expect(statPath).not.toHaveBeenCalled() + expect(worktree.lastActivityAt).toBe(10_000) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts new file mode 100644 index 00000000000..9026b4464d9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -0,0 +1,126 @@ +import { lstat, readFile } from 'node:fs/promises' +import path from 'node:path' +import type { Repo, Worktree } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> +type ReadTextFile = (targetPath: string) => Promise + +export function getPersistedWorkspaceCleanupActivityAt( + worktree: Pick +): number { + const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 + const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 + return Math.max(persistedActivityAt, createdAt) +} + +export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (persistedActivityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: persistedActivityAt } +} + +export async function resolveWorkspaceCleanupActivityWorktree( + repo: Repo, + worktree: Worktree, + statPath: StatPath = statLocalPath, + readTextFile: ReadTextFile = readLocalTextFile +): Promise { + const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + if (activityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: activityAt } +} + +async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { + const stats = await lstat(targetPath) + return { mtimeMs: Number(stats.mtimeMs) } +} + +async function readLocalTextFile(targetPath: string): Promise { + return readFile(targetPath, 'utf8') +} + +async function resolveWorkspaceCleanupActivityAt( + repo: Repo, + worktree: Worktree, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (repo.connectionId) { + return persistedActivityAt + } + + const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + worktree.path, + statPath, + readTextFile + ) + return Math.max(persistedActivityAt, filesystemActivityAt) +} + +// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely +// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the +// persisted activity timestamp instead of blocking or mislabeling the row. +async function getNewestLocalWorktreeStatMtime( + worktreePath: string, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const gitPath = path.join(worktreePath, '.git') + const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) + const gitDirMtimePromises = gitDirPath + ? [ + readMtime(gitDirPath, statPath), + readMtime(path.join(gitDirPath, 'HEAD'), statPath), + readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + ] + : [] + const mtimes = await Promise.all([ + readMtime(worktreePath, statPath), + readMtime(gitPath, statPath), + ...gitDirMtimePromises + ]) + return Math.max(0, ...mtimes) +} + +async function readLocalWorktreeGitDir( + worktreePath: string, + gitPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const contents = await readTextFile(gitPath) + const match = /^gitdir:\s*(.+)\s*$/im.exec(contents) + if (!match) { + return null + } + const gitDir = match[1]?.trim() + if (!gitDir) { + return null + } + // Why: linked worktrees keep mutable git state outside the worktree; the + // pointer file mtime alone can miss recent external commits. + const wslWorktree = parseWslUncPath(worktreePath) + if (wslWorktree && gitDir.startsWith('/')) { + return toWindowsWslPath(gitDir, wslWorktree.distro) + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir) + } catch { + return null + } +} + +async function readMtime(targetPath: string, statPath: StatPath): Promise { + try { + const stats = await statPath(targetPath) + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0 + } catch { + return 0 + } +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 33866e6cd73..a9b6ff704ca 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -3,7 +3,7 @@ import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import type { @@ -13,6 +13,11 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { + getPersistedWorkspaceCleanupActivityAt, + resolvePersistedWorkspaceCleanupActivityWorktree, + resolveWorkspaceCleanupActivityWorktree +} from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, buildWorkspaceCleanupCandidateFromError, @@ -124,48 +129,92 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const worktrees = gitWorktrees - .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - .filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) + const mergedWorktrees = gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + const candidateWorktrees = targetWorktreeId + ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + : mergedWorktrees.filter((worktree) => + shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) ) - }) - - onWorktreesDiscovered?.(worktrees.length) - - const candidates = await mapWorkspaceCleanupWithConcurrency( - worktrees, + // Why: fs stat has no cancellation, so on a hung network/WSL mount every + // timed-out row would abandon more threadpool work. After the first timeout, + // stop statting this repo and use persisted activity only. + let activityStatsUnavailable = false + const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( + candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + // Why: externally-created worktrees can miss Orca activity stamps; local + // filesystem metadata is a conservative guard before suggesting deletion. + const worktreeWithActivity = activityStatsUnavailable + ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + : await resolveCleanupActivityWithTimeout(repo, worktree, () => { + activityStatsUnavailable = true + }) + if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + return null + } + onWorktreesDiscovered?.(1) const candidate = await buildWorkspaceCleanupCandidate({ repo, - worktree, + worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) - return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) onCandidateScanned?.(candidate) return candidate } ) + const candidates = candidatesWithSkipped.filter( + (candidate): candidate is WorkspaceCleanupCandidate => candidate !== null + ) return { scannedAt, candidates, errors } } +async function resolveCleanupActivityWithTimeout( + repo: Repo, + worktree: Worktree, + onActivityStatsUnavailable: () => void +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.' + ) + } catch (error) { + onActivityStatsUnavailable() + console.warn('Workspace cleanup activity scan failed', error) + return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + } +} + +function shouldResolveBroadWorkspaceCleanupActivity( + repoIsFolder: boolean, + worktree: Worktree, + scannedAt: number +): boolean { + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} + async function listCleanupGitWorktrees( repo: Repo, repoIsFolder: boolean diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index af084713a0b..1d02e59db29 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' @@ -11,6 +12,8 @@ import type { import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { + lstatMock, + readFileMock, listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, @@ -18,6 +21,8 @@ const { getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -26,6 +31,11 @@ const { listRegisteredPtysMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -92,6 +102,22 @@ function buildWorktreeIds(repoId: string, count: number): string[] { return worktreeIds } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + ...overrides + } +} + function makeStore( options: { baseRef?: string @@ -123,6 +149,8 @@ describe('workspace cleanup scan', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) + lstatMock.mockReset() + readFileMock.mockReset() listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -130,6 +158,8 @@ describe('workspace cleanup scan', () => { getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) + lstatMock.mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockRejectedValue(new Error('not a gitdir pointer')) vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() listRepoWorktreesMock.mockResolvedValue([ @@ -199,7 +229,7 @@ describe('workspace cleanup scan', () => { expect(progress[0]).toMatchObject({ scanId: 'scan-1', scannedWorktreeCount: 0, - totalWorktreeCount: 2, + totalWorktreeCount: 1, candidates: [] }) const candidateProgress = progress.filter( @@ -222,6 +252,55 @@ describe('workspace cleanup scan', () => { }) }) + it('emits ready rows while another activity metadata read is stalled', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + lstatMock.mockImplementation((targetPath: string) => { + if (targetPath.startsWith('/repo-feature-a')) { + return new Promise(() => undefined) + } + return Promise.resolve({ mtimeMs: 0 }) + }) + const progress: WorkspaceCleanupScanProgress[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + await vi.waitFor(() => { + expect( + progress.some((event) => event.candidates[0]?.worktreeId === 'repo-1::/repo-feature-b') + ).toBe(true) + }) + expect(progress.at(-1)).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) @@ -273,18 +352,10 @@ describe('workspace cleanup scan', () => { it('uses direct metadata lookup for focused disconnected remote preflight', async () => { const targetWorktreeId = 'repo-1::/remote/repo-feature' - const targetMeta: WorktreeMeta = { + const targetMeta = makeWorktreeMeta({ displayName: 'Remote Feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 - } + }) const getWorktreeMeta = vi.fn((worktreeId: string) => worktreeId === targetWorktreeId ? targetMeta : undefined ) @@ -313,6 +384,35 @@ describe('workspace cleanup scan', () => { }) }) + it('stats only the requested worktree during focused local preflight scans', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-feature-b' + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature-b') + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-feature-b') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -385,6 +485,116 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('stats only broad-scan rows that remain possible cleanup candidates', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-new-created', + head: 'created123', + branch: 'refs/heads/created', + isBare: false, + isMainWorktree: false + } + ]) + const metadataByWorktreeId: Record = { + 'repo-1::/repo-old': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-new-created': makeWorktreeMeta({ + createdAt: NOW - 1_000, + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }) + } + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => metadataByWorktreeId[worktreeId] + } as unknown as Store + + const result = await scanWorkspaceCleanup(store) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-old') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-old', '.git')) + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('falls back when broad-scan activity metadata stalls', async () => { + lstatMock.mockReturnValue(new Promise(() => undefined)) + const progress: unknown[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + await vi.advanceTimersByTimeAsync(8_000) + + const result = await scanPromise + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature') + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 1 + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('stops statting a repo after the first activity metadata timeout', async () => { + listRepoWorktreesMock.mockResolvedValue( + ['/repo-a', '/repo-b', '/repo-c', '/repo-d'].map((worktreePath, index) => ({ + path: worktreePath, + head: `head-${index}`, + branch: `refs/heads/branch-${index}`, + isBare: false, + isMainWorktree: false + })) + ) + lstatMock.mockReturnValue(new Promise(() => undefined)) + + const scanPromise = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates).toHaveLength(4) + expect(lstatMock).not.toHaveBeenCalledWith('/repo-d') + expect(lstatMock).not.toHaveBeenCalledWith(path.join('/repo-d', '.git')) + }) + it('includes focused remove preflight rows even when they are recent', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index fd905bacf08..3c6910991f0 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -60,17 +60,21 @@ describe('repo-worktrees', () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } ]) + const signal = new AbortController().signal - const result = await listRepoWorktrees({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - kind: 'git' - }) + const result = await listRepoWorktrees( + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }, + { signal } + ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal }) expect(result).toHaveLength(1) }) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index e02ba35f9c7..4f45e7d8564 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -9,6 +9,10 @@ type LocalRepoWorktreeListOptions = { signal?: AbortSignal } +function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | undefined) { + return options?.wslDistro !== undefined || options?.signal !== undefined +} + export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { return repos.some( (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) @@ -30,7 +34,7 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo { export async function listRepoWorktrees( repo: Repo, - options: LocalRepoWorktreeListOptions = {} + options?: LocalRepoWorktreeListOptions ): Promise { if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] @@ -42,7 +46,7 @@ export async function listRepoWorktrees( // local git against a server path. return provider ? await provider.listWorktrees(repo.path) : [] } - return options.wslDistro + return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 0847c2ae8e7..07b84db43f9 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -24,7 +24,11 @@ type GitBufferSpyTarget = { } type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + git( + args: string[], + cwd: string, + opts?: { signal?: AbortSignal } + ): Promise<{ stdout: string; stderr: string }> } function deferredRelayBuffer(content: string): { @@ -1882,6 +1886,24 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) + it('passes request cancellation to the git worktree list subprocess', async () => { + const controller = new AbortController() + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockRejectedValue(new Error('aborted')) + + const result = await dispatcher.callRequest( + 'git.listWorktrees', + { repoPath: tmpDir }, + { isStale: () => false, signal: controller.signal } + ) + + expect(result).toEqual([]) + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { + signal: controller.signal + }) + }) + it.skipIf(process.platform === 'win32')( 'normalizes the main worktree path for a separate-git-dir repo', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 746dbffd803..2d4bb2bb09b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -216,7 +216,7 @@ export class GitHandler { this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) - this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) + this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) @@ -1263,10 +1263,12 @@ export class GitHandler { return normalized } - private async listWorktrees(params: Record) { + private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath( repoPath, parseWorktreeList(stdout, { nulDelimited: true }) @@ -1280,7 +1282,9 @@ export class GitHandler { // Why: `-z` keeps newline-containing SSH worktree paths intact, but older // Git rejects it. Fall back to the original line-block parser there. try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 003878d3acf..e0692a7f3bc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -606,6 +606,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false + const isQueuedForDeletion = deleteState?.phase === 'queued' + const deleteLabel = isQueuedForDeletion + ? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion') + : translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…') const deleteModifierPressed = useWorkspaceDeleteModifierPressed() const showStatus = cardProps.includes('status') @@ -1877,8 +1881,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ {isDeleting && (
- - {translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')} + {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel}
)} diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index efedfb65665..91a552735ef 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -4,8 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, - Check, - EyeOff, Loader2, RefreshCcw, Search, @@ -38,6 +36,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -48,16 +47,13 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupBlocker, type WorkspaceCleanupCandidate, type WorkspaceCleanupScanError, type WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' import { filterWorkspaceCleanupCandidates, - getWorkspaceCleanupGitLabel, getWorkspaceCleanupReviewInfo, - hasWorkspaceCleanupLocalContext, sortWorkspaceCleanupCandidates, type WorkspaceCleanupContextFilter, type WorkspaceCleanupFilters, @@ -68,11 +64,25 @@ import { type WorkspaceCleanupSortKey, type WorkspaceCleanupTimeFilter } from './workspace-cleanup-presentation' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { + getCandidateStatus, + getContextPillLabel, + getDirtyGitLabel, + getReviewPillTone, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' import { resolveWorkspaceCleanupActiveView, type WorkspaceCleanupView, type WorkspaceCleanupViewCounts } from './workspace-cleanup-view-selection' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' import { translate } from '@/i18n/i18n' const DEFAULT_FILTERS: WorkspaceCleanupFilters = { @@ -91,25 +101,6 @@ const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { title: null } -const BLOCKER_LABELS: Record = { - 'main-worktree': 'Main workspace', - 'folder-repo': 'Folder project', - pinned: 'Pinned', - 'active-workspace': 'Active workspace', - 'running-terminal': 'Running terminal process', - 'terminal-liveness-unknown': 'Terminal liveness unknown', - 'dirty-editor-buffer': 'Unsaved editor buffer', - 'volatile-local-context': 'Volatile local context', - 'recent-visible-context': 'Recently visited tabs', - 'live-agent': 'Active agent', - 'ssh-disconnected': 'Remote unavailable', - 'git-status-error': 'Git status unavailable', - 'dirty-files': 'Changed files', - 'unpushed-commits': 'Unpushed commits', - 'unknown-base': 'Could not verify unpushed commits', - dismissed: 'Ignored' -} - function formatRelativeTime(timestamp: number): string { if (!timestamp) { return 'Never' @@ -204,13 +195,29 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const deletingWorktreeIds = useAppStore( + useShallow( + (s) => + new Set( + Object.entries(s.deleteStateByWorktreeId) + .filter(([, state]) => state.isDeleting) + .map(([worktreeId]) => worktreeId) + ) + ) + ) const open = activeModal === 'workspace-cleanup' const openRef = useRef(open) const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [activeView, setActiveView] = useState('ready') const [confirming, setConfirming] = useState(false) - const [removing, setRemoving] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) const [rowFailures, setRowFailures] = useState>({}) const [repoSelection, setRepoSelection] = useState>(() => new Set()) const [filters, setFilters] = useState(DEFAULT_FILTERS) @@ -220,6 +227,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const autoScanAttemptedForOpenRef = useRef(false) const latestReadyToastScanAtRef = useRef(null) const wasOpenRef = useRef(false) + const removalInFlightRef = useRef(false) const mountedRef = useMountedRef() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) @@ -289,21 +297,21 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { if (!wasOpenRef.current) { wasOpenRef.current = true autoScanAttemptedForOpenRef.current = false - setActiveView('ready') - setConfirming(false) - setRowFailures({}) - setFilters(DEFAULT_FILTERS) - setSortKey('activity') - setSortDirection('asc') - if (scan) { - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (!removalInFlightRef.current) { + setActiveView('ready') + setConfirming(false) + setRowFailures({}) + setFilters(DEFAULT_FILTERS) + setSortKey('activity') + setSortDirection('asc') + setSelectedIds(new Set()) } } if (!loading && !autoScanAttemptedForOpenRef.current) { autoScanAttemptedForOpenRef.current = true startWorkspaceCleanupScan({ notifyWhenReady: true }) } - }, [loading, open, scan, startWorkspaceCleanupScan]) + }, [loading, open, startWorkspaceCleanupScan]) useEffect(() => { if (!open) { @@ -337,14 +345,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) useEffect(() => { - if (!scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } selectedDefaultsScanAtRef.current = scan.scannedAt - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (removalInFlightRef.current) { + return + } + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) setConfirming(false) setRowFailures({}) - }, [scan]) + }, [deletingWorktreeIds, loading, scan]) const visibleCandidates = useMemo(() => { const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) @@ -426,9 +437,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { .map((id) => byId.get(id)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => - candidate != null && canQueueWorkspaceCleanupCandidate(candidate) + candidate != null && + canQueueWorkspaceCleanupCandidate(candidate) && + !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, selectedIds]) + }, [activeRows, deletingWorktreeIds, selectedIds]) useEffect(() => { if (!open || confirming) { return @@ -436,18 +449,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { // Why: destructive selection must stay scoped to the rows the user can // currently review after tier/filter changes. setSelectedIds((current) => { - const next = new Set([...current].filter((id) => activeRowIds.has(id))) + const next = new Set( + [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, open]) + }, [activeRowIds, confirming, deletingWorktreeIds, open]) const handleOpenChange = useCallback( (nextOpen: boolean) => { - if (!nextOpen && !removing) { + if (!nextOpen) { closeModal() } }, - [closeModal, removing] + [closeModal] ) const refresh = useCallback(() => { @@ -483,65 +498,106 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [dismissCandidates, mountedRef] ) - const confirmRemove = useCallback(async () => { - if (selectedCandidates.length === 0) { + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { return } - setRemoving(true) - setRowFailures({}) - try { - const result = await removeCandidates( - selectedCandidates.map((candidate) => candidate.worktreeId) - ) - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - } - if (mountedRef.current) { - setRowFailures(nextFailures) - setSelectedIds((current) => { - const next = new Set(current) - for (const id of result.removedIds) { - next.delete(id) - } - return next - }) - } - if (result.removedIds.length > 0) { - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0f00612b6d', - 'Removed {{value0}} workspace{{value1}}', - { - value0: result.removedIds.length, - value1: result.removedIds.length === 1 ? '' : 's' - } - ) - ) - } - } - if (result.failures.length > 0) { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.41d594d01e', - '{{value0}} workspace{{value1}} could not be removed', - { value0: result.failures.length, value1: result.failures.length === 1 ? '' : 's' } - ) - ) - } - } else { - if (mountedRef.current) { - setConfirming(false) - } - } - } finally { - if (mountedRef.current) { - setRemoving(false) - } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return } - }, [mountedRef, removeCandidates, selectedCandidates]) + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRowFailures({}) + markWorktreesQueuedForDeletion(removableCandidates.map((candidate) => candidate.worktreeId)) + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + const deleteState = useAppStore.getState().deleteStateByWorktreeId[failure.worktreeId] + if ( + deleteState?.isDeleting && + deleteState.error === null && + deleteState.phase === 'queued' + ) { + // Why: candidates that fail before removal starts would otherwise + // stay marked "Queued for deletion" in the sidebar indefinitely. + clearWorktreeDeleteState(failure.worktreeId) + } + } + if (mountedRef.current) { + setRowFailures(nextFailures) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of result.removedIds) { + next.delete(id) + } + return next + }) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + }, + onError: () => { + for (const candidate of removableCandidates) { + clearWorktreeDeleteState(candidate.worktreeId) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + } + }) + }, [ + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + removeCandidates + ]) const selectedCount = selectedCandidates.length @@ -594,7 +650,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { 'Close' )} onClick={() => closeModal()} - disabled={removing} > @@ -649,8 +704,8 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { - ) : ( - - - ) -} - -function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { - label: string - tone: 'neutral' | 'ready' | 'review' | 'destructive' -} { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } - if (candidate.tier === 'ready') { - return { label: candidate.reasons.includes('archived') ? 'Archived' : 'Clean', tone: 'ready' } - } - if (candidate.blockers.length > 0) { - return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' } - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } - } - if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } - } - if (candidate.tier === 'review') { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', - 'Review' - ), - tone: 'review' - } - } - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ), - tone: 'neutral' - } -} - -function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { - const label = getWorkspaceCleanupGitLabel(candidate) - switch (label) { - case 'Clean': - return 'Clean git' - case 'Dirty': - return 'Dirty git' - case 'Unpushed': - return 'Unpushed commits' - case 'Unknown': - return 'Git unknown' - } - return 'Git unknown' -} - -function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { - const details: string[] = [] - if (candidate.git.upstreamAhead !== null) { - details.push( - candidate.git.upstreamAhead === 0 - ? 'No unpushed commits' - : `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - ) - } - return details -} - -function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { - const parts: string[] = [] - if (candidate.localContext.terminalTabCount > 0) { - parts.push( - `${candidate.localContext.terminalTabCount} terminal tab${ - candidate.localContext.terminalTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.cleanEditorTabCount > 0) { - parts.push( - `${candidate.localContext.cleanEditorTabCount} editor tab${ - candidate.localContext.cleanEditorTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.browserTabCount > 0) { - parts.push( - `${candidate.localContext.browserTabCount} browser tab${ - candidate.localContext.browserTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.diffCommentCount > 0) { - parts.push( - `${candidate.localContext.diffCommentCount} diff note${ - candidate.localContext.diffCommentCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.retainedDoneAgentCount > 0) { - parts.push( - `${candidate.localContext.retainedDoneAgentCount} completed agent${ - candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's' - }` - ) - } - return parts.length > 0 ? parts.join(', ') : null -} - function ConfirmRemove({ candidates, reviewInfoByWorktreeId, - removing, + progress, onCancel, onConfirm }: { candidates: WorkspaceCleanupCandidate[] reviewInfoByWorktreeId: ReadonlyMap - removing: boolean + progress: WorkspaceCleanupRemovalProgress | null onCancel: () => void onConfirm: () => void }): React.JSX.Element { const count = candidates.length - const noun = count === 1 ? 'workspace' : 'workspaces' + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 return ( <> -
-
- -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.cbf2f664e2', - 'Delete' - )}{' '} - {count} {noun}? - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." +
+
+
+ {deleting ? ( + + ) : ( + )} - +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null}
- {count} {noun}{' '} {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.dba753e94f', - 'to delete' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } )}
@@ -1541,20 +1316,27 @@ function ConfirmRemove({
- - + {!deleting ? ( + + ) : null} ) @@ -1572,6 +1354,7 @@ function ConfirmRemoveRow({ const dirtyLabel = getDirtyGitLabel(candidate) const branchDiffersFromName = candidate.branch !== candidate.displayName const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const status = getCandidateStatus(candidate) return (
@@ -1589,7 +1372,9 @@ function ConfirmRemoveRow({ {reviewInfo.label} ) : null} {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel ? {dirtyLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null}
{candidate.repoName} @@ -1607,55 +1392,6 @@ function ConfirmRemoveRow({ ) } -function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (candidate.blockers.includes('unpushed-commits')) { - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - return 'Unpushed commits' - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - if (candidate.git.clean === false) { - return 'Uncommitted changes' - } - if ( - candidate.git.clean == null || - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return 'Git status unknown' - } - return null -} - -function getReviewPillTone( - reviewInfo: WorkspaceCleanupReviewInfo -): 'neutral' | 'ready' | 'review' | 'destructive' { - if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { - return 'review' - } - return 'neutral' -} - -function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (!hasWorkspaceCleanupLocalContext(candidate)) { - return null - } - const count = - candidate.localContext.terminalTabCount + - candidate.localContext.cleanEditorTabCount + - candidate.localContext.browserTabCount + - candidate.localContext.diffCommentCount + - candidate.localContext.retainedDoneAgentCount - return `${count} context` -} - function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { return ( filters.query.trim() !== '' || @@ -1682,11 +1418,14 @@ function hasActiveWorkspaceCleanupPanelControls( } function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[] + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() ): Set { return new Set( candidates - .filter((candidate) => candidate.selectedByDefault) + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) .map((candidate) => candidate.worktreeId) ) } @@ -1703,21 +1442,40 @@ function formatWorkspaceCleanupReadyToastDescription( return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` } +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { + value0: progress.removedCount, + value1: progress.totalCount + } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { + value0: deletedText, + value1: progress.failedCount + } + ) +} + function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.totalWorktreeCount === 0) { + if (!progress || progress.scannedWorktreeCount === 0) { return translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', 'Finding inactive workspaces...' ) } - const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces' return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480', - '{{value0}}/{{value1}} {{value2}} scanned', + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', { - value0: progress.scannedWorktreeCount, - value1: progress.totalWorktreeCount, - value2: noun + value0: progress.scannedWorktreeCount } ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts new file mode 100644 index 00000000000..ec3a4114a97 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupBackgroundRemovalArgs +} from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('startWorkspaceCleanupBackgroundRemoval', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports an empty result without starting removal when there are no candidates', async () => { + const removeCandidates = vi.fn() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).not.toHaveBeenCalled() + expect(onProgress).not.toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] }) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports deletion progress while the slow removal promise is pending', async () => { + let resolveRemoval: ( + result: Awaited> + ) => void + const removeCandidates = vi.fn( + () => + new Promise>>( + (resolve) => { + resolveRemoval = resolve + } + ) + ) + const onProgress = vi.fn() + const onResult = vi.fn() + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + onProgress, + onResult + }) + + expect(removeCandidates).toHaveBeenCalledWith([candidate.worktreeId], { + approvedCandidates: [candidate] + }) + expect(onProgress).toHaveBeenCalledWith({ + totalCount: 1, + processedCount: 0, + removedCount: 0, + failedCount: 0 + }) + expect(onResult).not.toHaveBeenCalled() + + resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 1, + failedCount: 0 + }) + expect(toast.success).toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] }) + }) + + it('removes candidates one at a time for per-row progress', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] }) + .mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] }) + const onProgress = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 2, + failedCount: 0 + }) + }) + + it('removes nested candidates before their parent workspace', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({ + removedIds: [...worktreeIds], + failures: [] + })) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [child.worktreeId], { + approvedCandidates: [child] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + }) + + it('skips an ancestor after a nested workspace removal fails', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(removeCandidates).toHaveBeenCalledWith([child.worktreeId], { + approvedCandidates: [child] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 0, + failedCount: 2 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('does not skip same-path ancestors from another connection after a nested failure', async () => { + const failedChild = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child', + connectionId: 'ssh-a' + }) + const unrelatedParent = makeCandidate({ + worktreeId: 'repo-2::/repo/parent', + repoId: 'repo-2', + repoName: 'Repo 2', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent', + connectionId: 'ssh-b' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { + worktreeId: failedChild.worktreeId, + displayName: failedChild.displayName, + message: 'busy' + } + ] + }) + .mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [unrelatedParent, failedChild], + removeCandidates, + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], { + approvedCandidates: [failedChild] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], { + approvedCandidates: [unrelatedParent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [unrelatedParent.worktreeId], + failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }] + }) + }) + + it('reports removal failures after dismissing the pending toast', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [], + failures: [ + { worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ description: 'busy' }) + ) + }) + + it('times out a stalled row removal and continues reporting progress', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise< + Awaited> + >(() => undefined) + ), + onProgress, + onResult, + removalTimeoutMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 0, + failedCount: 1 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: + 'Removing alpha is taking longer than expected. It will keep running in the background.' + } + ] + }) + }) + + it('keeps removal outcome toasts when the result callback throws', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [candidate.worktreeId], + failures: [] + }), + onProgress: vi.fn(), + onResult: () => { + throw new Error('callback failed') + }, + onError: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.success).toHaveBeenCalledWith('Removed workspaces: 1') + expect(toast.error).not.toHaveBeenCalledWith( + 'Workspace cleanup failed', + expect.objectContaining({ description: 'callback failed' }) + ) + }) + + it('shows every failure message in the failure toast description', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' } + ] + }) + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 2', + expect.objectContaining({ description: 'busy; dirty' }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts new file mode 100644 index 00000000000..a8d14923cba --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -0,0 +1,216 @@ +import { toast } from 'sonner' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveOptions, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { translate } from '@/i18n/i18n' + +const DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS = 120_000 + +export type WorkspaceCleanupRemovalProgress = { + totalCount: number + processedCount: number + removedCount: number + failedCount: number +} + +export type WorkspaceCleanupBackgroundRemovalArgs = { + candidates: readonly WorkspaceCleanupCandidate[] + removeCandidates: ( + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions + ) => Promise + onProgress: (progress: WorkspaceCleanupRemovalProgress) => void + onResult?: (result: WorkspaceCleanupRemoveResult) => void + onError?: (error: unknown) => void + removalTimeoutMs?: number +} + +export function startWorkspaceCleanupBackgroundRemoval({ + candidates, + removeCandidates, + onProgress, + onResult, + onError, + removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS +}: WorkspaceCleanupBackgroundRemovalArgs): void { + if (candidates.length === 0) { + try { + onResult?.({ removedIds: [], failures: [] }) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + return + } + + const count = candidates.length + const removedIds: string[] = [] + const failures: WorkspaceCleanupFailure[] = [] + const failedCandidates: WorkspaceCleanupCandidate[] = [] + let processedCount = 0 + + const emitProgress = (): void => { + onProgress({ + totalCount: count, + processedCount, + removedCount: removedIds.length, + failedCount: failures.length + }) + } + + emitProgress() + + // Why: keep the store's nested-worktree delete invariant even though progress + // is emitted per row; children must be removed before parent workspaces. + const candidatesInRemovalOrder = [...candidates].sort((a, b) => b.path.length - a.path.length) + + void (async () => { + for (const candidate of candidatesInRemovalOrder) { + if ( + failedCandidates.some((failedCandidate) => + isStrictWorkspaceCleanupDescendant(candidate, failedCandidate) + ) + ) { + failedCandidates.push(candidate) + failures.push({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', + 'Skipped because a nested workspace could not be removed.' + ) + }) + processedCount += 1 + emitProgress() + continue + } + try { + const result = await withWorkspaceCleanupRemovalTimeout( + removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), + candidate, + removalTimeoutMs + ) + removedIds.push(...result.removedIds) + failures.push(...result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + failures.push({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + }) + } finally { + processedCount += 1 + emitProgress() + } + } + + const result = { removedIds, failures } + try { + onResult?.(result) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + + if (result.removedIds.length > 0) { + toast.success( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.removed', + 'Removed workspaces: {{value0}}', + { + value0: result.removedIds.length + } + ) + ) + } + + if (result.failures.length > 0) { + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.failed', + 'Workspaces not removed: {{value0}}', + { + value0: result.failures.length + } + ), + { + description: result.failures.map((failure) => failure.message).join('; ') + } + ) + } + })().catch((error: unknown) => { + onError?.(error) + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.error', + 'Workspace cleanup failed' + ), + { + description: error instanceof Error ? error.message : String(error) + } + ) + }) +} + +async function withWorkspaceCleanupRemovalTimeout( + promise: Promise, + candidate: WorkspaceCleanupCandidate, + timeoutMs: number +): Promise { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return promise + } + + let timeout: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + // Why: the underlying removal cannot be cancelled from the renderer, + // so the row stays "Deleting" and this message must not claim the + // removal stopped. + reject( + new Error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', + 'Removing {{value0}} is taking longer than expected. It will keep running in the background.', + { value0: candidate.displayName } + ) + ) + ) + }, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function isStrictWorkspaceCleanupDescendant( + parent: WorkspaceCleanupCandidate, + child: WorkspaceCleanupCandidate +): boolean { + return ( + parent.connectionId === child.connectionId && + isStrictWorkspaceCleanupDescendantPath(parent.path, child.path) + ) +} + +function isStrictWorkspaceCleanupDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts new file mode 100644 index 00000000000..78b98f9b4d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts @@ -0,0 +1,176 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBlocker } from '../../../../shared/workspace-cleanup' + +type ContextDetailKind = 'terminal' | 'editor' | 'browser' | 'diff' | 'agent' + +export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker): string { + switch (blocker) { + case 'main-worktree': + return translate( + 'auto.components.workspace.cleanup.candidateRow.mainWorkspaceBlocker', + 'Main workspace' + ) + case 'folder-repo': + return translate( + 'auto.components.workspace.cleanup.candidateRow.folderProjectBlocker', + 'Folder project' + ) + case 'pinned': + return translate('auto.components.workspace.cleanup.candidateRow.pinnedBlocker', 'Pinned') + case 'active-workspace': + return translate( + 'auto.components.workspace.cleanup.candidateRow.activeWorkspaceBlocker', + 'Active workspace' + ) + case 'running-terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.runningTerminalBlocker', + 'Running terminal process' + ) + case 'terminal-liveness-unknown': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalLivenessUnknownBlocker', + 'Terminal liveness unknown' + ) + case 'dirty-editor-buffer': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyEditorBufferBlocker', + 'Unsaved editor buffer' + ) + case 'volatile-local-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.volatileLocalContextBlocker', + 'Volatile local context' + ) + case 'recent-visible-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.recentVisibleContextBlocker', + 'Recently visited tabs' + ) + case 'live-agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.liveAgentBlocker', + 'Active agent' + ) + case 'ssh-disconnected': + return translate( + 'auto.components.workspace.cleanup.candidateRow.sshDisconnectedBlocker', + 'Remote unavailable' + ) + case 'git-status-error': + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusErrorBlocker', + 'Git status unavailable' + ) + case 'dirty-files': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyFilesBlocker', + 'Changed files' + ) + case 'unpushed-commits': + return getUnpushedCommitsLabel() + case 'unknown-base': + return translate( + 'auto.components.workspace.cleanup.candidateRow.unknownBaseBlocker', + 'Could not verify unpushed commits' + ) + case 'dismissed': + return translate('auto.components.workspace.cleanup.candidateRow.dismissedBlocker', 'Ignored') + } +} + +export function formatWorkspaceCleanupGitStatusLabel(label: string): string { + switch (label) { + case 'Clean': + return translate('auto.components.workspace.cleanup.candidateRow.cleanGit', 'Clean git') + case 'Dirty': + return translate('auto.components.workspace.cleanup.candidateRow.dirtyGit', 'Dirty git') + case 'Unpushed': + return getUnpushedCommitsLabel() + case 'Unknown': + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') + } + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') +} + +export function getNoUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.noUnpushedCommits', + 'No unpushed commits' + ) +} + +export function getUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommits', + 'Unpushed commits' + ) +} + +export function formatUnpushedCommitCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommitsCount', + 'Unpushed commits: {{value0}}', + { value0: count } + ) +} + +export function getUncommittedChangesLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.uncommittedChanges', + 'Uncommitted changes' + ) +} + +export function getGitStatusUnknownLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusUnknown', + 'Git status unknown' + ) +} + +export function formatWorkspaceCleanupContextDetail( + kind: ContextDetailKind, + count: number +): string { + switch (kind) { + case 'terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalTabsCount', + 'Terminal tabs: {{value0}}', + { value0: count } + ) + case 'editor': + return translate( + 'auto.components.workspace.cleanup.candidateRow.editorTabsCount', + 'Editor tabs: {{value0}}', + { value0: count } + ) + case 'browser': + return translate( + 'auto.components.workspace.cleanup.candidateRow.browserTabsCount', + 'Browser tabs: {{value0}}', + { value0: count } + ) + case 'diff': + return translate( + 'auto.components.workspace.cleanup.candidateRow.diffNotesCount', + 'Diff notes: {{value0}}', + { value0: count } + ) + case 'agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.completedAgentsCount', + 'Completed agents: {{value0}}', + { value0: count } + ) + } +} + +export function formatWorkspaceCleanupContextCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.contextCount', + 'Context: {{value0}}', + { value0: count } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts new file mode 100644 index 00000000000..3cd5309ac54 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +describe('workspace cleanup candidate row data', () => { + it('does not duplicate git status blockers as a separate git icon label', () => { + const gitStatusError = makeCandidate({ + blockers: ['git-status-error'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const unknownBase = makeCandidate({ + blockers: ['unknown-base'], + git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + }) + + expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) + expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) + }) + + it('keeps the git metadata chip for ordinary clean rows', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(true) + }) + + it('suppresses the git metadata chip when the status pill already names git risk', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + tier: 'review', + blockers: [], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts new file mode 100644 index 00000000000..9e819d90017 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -0,0 +1,204 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + hasWorkspaceCleanupLocalContext, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { + formatUnpushedCommitCount, + formatWorkspaceCleanupContextCount, + formatWorkspaceCleanupContextDetail, + formatWorkspaceCleanupGitStatusLabel, + getGitStatusUnknownLabel, + getNoUnpushedCommitsLabel, + getUncommittedChangesLabel, + getUnpushedCommitsLabel, + getWorkspaceCleanupBlockerLabel +} from './workspace-cleanup-candidate-labels' + +export type StatusPillTone = 'neutral' | 'ready' | 'review' | 'destructive' + +export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCandidate): string[] { + return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) +} + +export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { + label: string + tone: StatusPillTone +} { + if (candidate.blockers.includes('dismissed')) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ), + tone: 'neutral' + } + } + if (candidate.tier === 'ready') { + return { + label: candidate.reasons.includes('archived') + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus', + 'Ready' + ), + tone: 'ready' + } + } + if (candidate.blockers.length > 0) { + return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + } + if (candidate.git.clean === false) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + } + if (candidate.tier === 'review') { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', + 'Review' + ), + tone: 'review' + } + } + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', + 'Not suggested' + ), + tone: 'neutral' + } +} + +export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { + return formatWorkspaceCleanupGitStatusLabel(getWorkspaceCleanupGitLabel(candidate)) +} + +export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { + const details: string[] = [] + if (candidate.git.upstreamAhead !== null) { + details.push( + candidate.git.upstreamAhead === 0 + ? getNoUnpushedCommitsLabel() + : formatUnpushedCommitCount(candidate.git.upstreamAhead) + ) + } + return details +} + +export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { + const parts: string[] = [] + if (candidate.localContext.terminalTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('terminal', candidate.localContext.terminalTabCount) + ) + } + if (candidate.localContext.cleanEditorTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('editor', candidate.localContext.cleanEditorTabCount) + ) + } + if (candidate.localContext.browserTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('browser', candidate.localContext.browserTabCount) + ) + } + if (candidate.localContext.diffCommentCount > 0) { + parts.push(formatWorkspaceCleanupContextDetail('diff', candidate.localContext.diffCommentCount)) + } + if (candidate.localContext.retainedDoneAgentCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount) + ) + } + return parts.length > 0 ? parts.join(', ') : null +} + +export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { + if ( + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') + ) { + return null + } + if (candidate.blockers.includes('unpushed-commits')) { + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + return getUnpushedCommitsLabel() + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + if (candidate.git.clean === false) { + return getUncommittedChangesLabel() + } + if (candidate.git.clean == null) { + return getGitStatusUnknownLabel() + } + return null +} + +export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { + return ( + !candidate.blockers.includes('unknown-base') && + !candidate.blockers.includes('git-status-error') && + !hasGitStatusPill(candidate) + ) +} + +function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { + if ( + candidate.blockers.includes('dirty-files') || + candidate.blockers.includes('unpushed-commits') + ) { + return true + } + if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + return false + } + return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false +} + +export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { + if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { + return 'review' + } + return 'neutral' +} + +export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { + if (!hasWorkspaceCleanupLocalContext(candidate)) { + return null + } + return formatWorkspaceCleanupContextCount(getContextCount(candidate)) +} + +export function getContextCount(candidate: WorkspaceCleanupCandidate): number { + return ( + candidate.localContext.terminalTabCount + + candidate.localContext.cleanEditorTabCount + + candidate.localContext.browserTabCount + + candidate.localContext.diffCommentCount + + candidate.localContext.retainedDoneAgentCount + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx new file mode 100644 index 00000000000..8862d2ad1e4 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatGitStatus } from './workspace-cleanup-candidate-row-data' + +type CandidateRowDetailsProps = { + blockers: string[] + branchSafetyDetails: string[] + candidate: WorkspaceCleanupCandidate + contextDetails: string | null + expanded: boolean +} + +export function CandidateRowDetails({ + blockers, + branchSafetyDetails, + candidate, + contextDetails, + expanded +}: CandidateRowDetailsProps): React.JSX.Element { + return ( +
+
+
+
+ + + + {branchSafetyDetails.slice(0, 1).map((detail) => ( + + ))} + {contextDetails ? ( + + ) : null} + {blockers.length > 0 ? ( + + ) : null} +
+
+ {candidate.path} +
+
+
+
+ ) +} + +function DetailLine({ + label, + mono = false, + value +}: { + label: string + mono?: boolean + value: string +}): React.JSX.Element { + return ( +
+ + {label} + + {value} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx new file mode 100644 index 00000000000..b65e495a7ea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('CandidateRow', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('hides selection and remove controls while the workspace is already deleting', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() + expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx new file mode 100644 index 00000000000..04fb02d28c2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -0,0 +1,342 @@ +import React from 'react' +import { + AlertTriangle, + Check, + ChevronDown, + Clock3, + EyeOff, + FileWarning, + GitBranch, + GitPullRequest, + Search, + SquareTerminal, + Trash2, + type LucideIcon +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + canQueueWorkspaceCleanupCandidate, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { CandidateRowDetails } from './workspace-cleanup-candidate-row-details' +import { + formatBranchSafetyDetails, + formatContextDetails, + formatGitStatus, + getCandidateStatus, + getContextCount, + getDirtyGitLabel, + getReviewPillTone, + getWorkspaceCleanupBlockerLabels, + shouldShowGitMetadataChip, + type StatusPillTone +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' + +type CandidateRowProps = { + candidate: WorkspaceCleanupCandidate + expanded: boolean + failure?: string + last: boolean + lastActivityLabel: string + removing?: boolean + reviewInfo: WorkspaceCleanupReviewInfo + selected: boolean + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void +} + +function MetadataIconChip({ + icon: Icon, + label, + value, + tone = 'neutral' +}: { + icon: LucideIcon + label: string + value?: string + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + + + + + + {label} + + + ) +} + +export function CandidateRow({ + candidate, + expanded, + failure, + last, + lastActivityLabel, + removing = false, + reviewInfo, + selected, + onIgnore, + onRemove, + onToggleExpanded, + onToggleSelected, + onView +}: CandidateRowProps): React.JSX.Element { + const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing + const ignored = candidate.blockers.includes('dismissed') + const blockers = getWorkspaceCleanupBlockerLabels(candidate) + const contextDetails = formatContextDetails(candidate) + const branchSafetyDetails = formatBranchSafetyDetails(candidate) + const status = getCandidateStatus(candidate) + const dirtyLabel = getDirtyGitLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const contextCount = getContextCount(candidate) + const hasExpandableDetails = + blockers.length > 0 || + candidate.path.length > 0 || + candidate.branch.length > 0 || + contextDetails !== null || + branchSafetyDetails.length > 0 + + return ( +
+
+ {selectable ? ( + + ) : ( + +
+ ) +} + +function formatCompactActivityLabel(label: string): string { + if (label === 'Just now') { + return 'now' + } + return label.replace(/ ago$/, '') +} + +function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + const parts = [reviewInfo.label] + if (reviewInfo.state) { + parts.push(reviewInfo.state) + } + if (reviewInfo.title) { + parts.push(reviewInfo.title) + } + return parts.filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts new file mode 100644 index 00000000000..5b5f000c19c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +describe('workspace cleanup removal candidates', () => { + it('excludes workspaces already being deleted', () => { + const deleting = makeCandidate({ worktreeId: 'repo-1::/repo/deleting' }) + const ready = makeCandidate({ worktreeId: 'repo-1::/repo/ready' }) + + expect( + filterWorkspaceCleanupRemovalCandidates([deleting, ready], { + [deleting.worktreeId]: { isDeleting: true } + }) + ).toEqual([ready]) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts new file mode 100644 index 00000000000..36c70257929 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts @@ -0,0 +1,13 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorktreeDeleteState } from '@/store/slices/worktrees' + +type DeletionFlagState = Pick + +export function filterWorkspaceCleanupRemovalCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + deleteStateByWorktreeId: Record +): WorkspaceCleanupCandidate[] { + return candidates.filter( + (candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx new file mode 100644 index 00000000000..1e5786fda49 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' + +export function StatusPill({ + children, + tone = 'neutral' +}: { + children: React.ReactNode + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 570c20c6c54..177a31b2850 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "Workspace no longer exists." + "9d6e531da6": "Workspace no longer exists.", + "changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Context", "sortBy": "Sort by", - "sortDirection": "Direction" + "sortDirection": "Direction", + "1d3503357d": "You can close this and come back while deletion continues.", + "4c2990886e": "{{value0}}/{{value1}} deleted", + "86ba852118": "{{value0}}, {{value1}} failed", + "7b7bde5181": "Checked workspaces so far: {{value0}}", + "deletingCount": "Deleting workspaces: {{value0}}", + "deleteCount": "Delete workspaces: {{value0}}?", + "selectedForDeletionCount": "Selected for deletion: {{value0}}", + "deleteButtonCount": "Delete {{value0}}", + "archivedStatus": "Archived", + "readyStatus": "Ready" + }, + "backgroundRemoval": { + "removed": "Removed workspaces: {{value0}}", + "failed": "Workspaces not removed: {{value0}}", + "error": "Workspace cleanup failed", + "skippedAncestor": "Skipped because a nested workspace could not be removed.", + "timedOut": "Removing {{value0}} is taking longer than expected. It will keep running in the background." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Commits", + "contextLabel": "Context", + "flagsLabel": "Flags", + "collapseDetails": "Collapse details", + "expandDetails": "Expand details", + "cleanGit": "Clean git", + "dirtyGit": "Dirty git", + "unpushedCommits": "Unpushed commits", + "gitUnknown": "Git unknown", + "gitStatusUnknown": "Git status unknown", + "noUnpushedCommits": "No unpushed commits", + "unpushedCommitsCount": "Unpushed commits: {{value0}}", + "uncommittedChanges": "Uncommitted changes", + "terminalTabsCount": "Terminal tabs: {{value0}}", + "editorTabsCount": "Editor tabs: {{value0}}", + "browserTabsCount": "Browser tabs: {{value0}}", + "diffNotesCount": "Diff notes: {{value0}}", + "completedAgentsCount": "Completed agents: {{value0}}", + "contextCount": "Context: {{value0}}", + "mainWorkspaceBlocker": "Main workspace", + "folderProjectBlocker": "Folder project", + "pinnedBlocker": "Pinned", + "activeWorkspaceBlocker": "Active workspace", + "runningTerminalBlocker": "Running terminal process", + "terminalLivenessUnknownBlocker": "Terminal liveness unknown", + "dirtyEditorBufferBlocker": "Unsaved editor buffer", + "volatileLocalContextBlocker": "Volatile local context", + "recentVisibleContextBlocker": "Recently visited tabs", + "liveAgentBlocker": "Active agent", + "sshDisconnectedBlocker": "Remote unavailable", + "gitStatusErrorBlocker": "Git status unavailable", + "dirtyFilesBlocker": "Changed files", + "unknownBaseBlocker": "Could not verify unpushed commits", + "dismissedBlocker": "Ignored" } } }, @@ -4027,7 +4082,8 @@ "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", "branchIdentity": "Branch", - "branchFolderPathIdentity": "Branch or folder path" + "branchFolderPathIdentity": "Branch or folder path", + "ef18787206": "Queued for deletion" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2543c3ce6d3..e4c1f062caf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "El espacio de trabajo ya no existe." + "9d6e531da6": "El espacio de trabajo ya no existe.", + "changedSinceConfirmation": "El espacio de trabajo cambió después de la confirmación. Actualiza para revisarlo antes de eliminarlo." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Contexto", "sortBy": "Ordenar por", - "sortDirection": "Dirección" + "sortDirection": "Dirección", + "1d3503357d": "Puedes cerrar esto y volver mientras la eliminación continúa.", + "4c2990886e": "{{value0}}/{{value1}} eliminados", + "86ba852118": "{{value0}}, {{value1}} fallidos", + "7b7bde5181": "Espacios de trabajo comprobados hasta ahora: {{value0}}", + "deletingCount": "Eliminando espacios de trabajo: {{value0}}", + "deleteCount": "¿Eliminar espacios de trabajo: {{value0}}?", + "selectedForDeletionCount": "Seleccionados para eliminar: {{value0}}", + "deleteButtonCount": "Eliminar {{value0}}", + "archivedStatus": "Archivado", + "readyStatus": "Listo" + }, + "backgroundRemoval": { + "removed": "Espacios de trabajo eliminados: {{value0}}", + "failed": "Espacios de trabajo no eliminados: {{value0}}", + "error": "Error en la limpieza del espacio de trabajo", + "skippedAncestor": "Se omitió porque no se pudo eliminar un espacio de trabajo anidado.", + "timedOut": "La eliminación de {{value0}} está tardando más de lo esperado. Continuará ejecutándose en segundo plano." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Confirmaciones", + "contextLabel": "Contexto", + "flagsLabel": "Indicadores", + "collapseDetails": "Contraer detalles", + "expandDetails": "Expandir detalles", + "cleanGit": "Git limpio", + "dirtyGit": "Git con cambios", + "unpushedCommits": "Confirmaciones sin enviar", + "gitUnknown": "Git desconocido", + "gitStatusUnknown": "Estado de Git desconocido", + "noUnpushedCommits": "Sin confirmaciones sin enviar", + "unpushedCommitsCount": "Confirmaciones sin enviar: {{value0}}", + "uncommittedChanges": "Cambios sin confirmar", + "terminalTabsCount": "Pestañas de terminal: {{value0}}", + "editorTabsCount": "Pestañas de editor: {{value0}}", + "browserTabsCount": "Pestañas de navegador: {{value0}}", + "diffNotesCount": "Notas de diff: {{value0}}", + "completedAgentsCount": "Agentes completados: {{value0}}", + "contextCount": "Contexto: {{value0}}", + "mainWorkspaceBlocker": "Espacio de trabajo principal", + "folderProjectBlocker": "Proyecto de carpeta", + "pinnedBlocker": "Fijado", + "activeWorkspaceBlocker": "Espacio de trabajo activo", + "runningTerminalBlocker": "Proceso de terminal en ejecución", + "terminalLivenessUnknownBlocker": "Estado del terminal desconocido", + "dirtyEditorBufferBlocker": "Búfer del editor sin guardar", + "volatileLocalContextBlocker": "Contexto local volátil", + "recentVisibleContextBlocker": "Pestañas visitadas recientemente", + "liveAgentBlocker": "Agente activo", + "sshDisconnectedBlocker": "Remoto no disponible", + "gitStatusErrorBlocker": "Estado de Git no disponible", + "dirtyFilesBlocker": "Archivos modificados", + "unknownBaseBlocker": "No se pudieron verificar confirmaciones sin enviar", + "dismissedBlocker": "Ignorado" } } }, @@ -4004,7 +4059,8 @@ "runtimeHostProject": "Proyecto en servidor de Orca", "automationCreated": "Creado por automatización", "branchIdentity": "Rama", - "branchFolderPathIdentity": "Rama o ruta de carpeta" + "branchFolderPathIdentity": "Rama o ruta de carpeta", + "ef18787206": "En cola para eliminación" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9727ae4adc4..01e1c9d6973 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "ワークスペースはもう存在しません。" + "9d6e531da6": "ワークスペースはもう存在しません。", + "changedSinceConfirmation": "確認後にワークスペースが変更されました。削除する前に更新して確認してください。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "コンテクスト", "sortBy": "並べ替え", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "削除の続行中にこれを閉じて後で戻れます。", + "4c2990886e": "{{value0}}/{{value1}} 件を削除済み", + "86ba852118": "{{value0}}、{{value1}} 件が失敗", + "7b7bde5181": "これまでに確認したワークスペース: {{value0}}", + "deletingCount": "削除中のワークスペース: {{value0}}", + "deleteCount": "ワークスペースを削除しますか: {{value0}}?", + "selectedForDeletionCount": "削除対象として選択済み: {{value0}}", + "deleteButtonCount": "{{value0}} 件を削除", + "archivedStatus": "アーカイブ済み", + "readyStatus": "準備完了" + }, + "backgroundRemoval": { + "removed": "削除済みワークスペース: {{value0}}", + "failed": "削除できなかったワークスペース: {{value0}}", + "error": "ワークスペースのクリーンアップに失敗しました", + "skippedAncestor": "ネストされたワークスペースを削除できなかったためスキップしました。", + "timedOut": "{{value0}} の削除に予想より時間がかかっています。バックグラウンドで継続されます。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "コミット", + "contextLabel": "コンテキスト", + "flagsLabel": "フラグ", + "collapseDetails": "詳細を折りたたむ", + "expandDetails": "詳細を展開", + "cleanGit": "Git はクリーン", + "dirtyGit": "Git に変更あり", + "unpushedCommits": "未プッシュのコミット", + "gitUnknown": "Git 不明", + "gitStatusUnknown": "Git 状態不明", + "noUnpushedCommits": "未プッシュのコミットなし", + "unpushedCommitsCount": "未プッシュのコミット: {{value0}}", + "uncommittedChanges": "未コミットの変更", + "terminalTabsCount": "ターミナルタブ: {{value0}}", + "editorTabsCount": "エディタタブ: {{value0}}", + "browserTabsCount": "ブラウザタブ: {{value0}}", + "diffNotesCount": "差分メモ: {{value0}}", + "completedAgentsCount": "完了済みエージェント: {{value0}}", + "contextCount": "コンテキスト: {{value0}}", + "mainWorkspaceBlocker": "メインワークスペース", + "folderProjectBlocker": "フォルダープロジェクト", + "pinnedBlocker": "ピン留め済み", + "activeWorkspaceBlocker": "アクティブなワークスペース", + "runningTerminalBlocker": "実行中のターミナルプロセス", + "terminalLivenessUnknownBlocker": "ターミナルの状態不明", + "dirtyEditorBufferBlocker": "未保存のエディタバッファ", + "volatileLocalContextBlocker": "揮発性のローカルコンテキスト", + "recentVisibleContextBlocker": "最近表示したタブ", + "liveAgentBlocker": "アクティブなエージェント", + "sshDisconnectedBlocker": "リモート利用不可", + "gitStatusErrorBlocker": "Git 状態を利用できません", + "dirtyFilesBlocker": "変更済みファイル", + "unknownBaseBlocker": "未プッシュのコミットを確認できませんでした", + "dismissedBlocker": "無視済み" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca サーバー上のプロジェクト", "automationCreated": "自動化により作成", "branchIdentity": "ブランチ", - "branchFolderPathIdentity": "ブランチまたはフォルダパス" + "branchFolderPathIdentity": "ブランチまたはフォルダパス", + "ef18787206": "削除待ち" }, "WorktreeCardAgents": { "1b0a156717": "エージェント" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bff86bf9af6..203fa2c5789 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다." + "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.", + "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "문맥", "sortBy": "정렬 기준", - "sortDirection": "방향" + "sortDirection": "방향", + "1d3503357d": "삭제가 계속되는 동안 이 창을 닫고 나중에 돌아올 수 있습니다.", + "4c2990886e": "{{value0}}/{{value1}}개 삭제됨", + "86ba852118": "{{value0}}, {{value1}}개 실패", + "7b7bde5181": "지금까지 확인한 워크스페이스: {{value0}}", + "deletingCount": "삭제 중인 워크스페이스: {{value0}}", + "deleteCount": "워크스페이스 삭제: {{value0}}?", + "selectedForDeletionCount": "삭제 대상으로 선택됨: {{value0}}", + "deleteButtonCount": "{{value0}}개 삭제", + "archivedStatus": "보관됨", + "readyStatus": "준비됨" + }, + "backgroundRemoval": { + "removed": "삭제된 워크스페이스: {{value0}}", + "failed": "삭제되지 않은 워크스페이스: {{value0}}", + "error": "워크스페이스 정리 실패", + "skippedAncestor": "중첩된 워크스페이스를 제거할 수 없어 건너뛰었습니다.", + "timedOut": "{{value0}} 제거가 예상보다 오래 걸리고 있습니다. 백그라운드에서 계속 실행됩니다." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "커밋", + "contextLabel": "문맥", + "flagsLabel": "플래그", + "collapseDetails": "세부 정보 접기", + "expandDetails": "세부 정보 펼치기", + "cleanGit": "Git 깨끗함", + "dirtyGit": "Git 변경 있음", + "unpushedCommits": "푸시되지 않은 커밋", + "gitUnknown": "Git 알 수 없음", + "gitStatusUnknown": "Git 상태 알 수 없음", + "noUnpushedCommits": "푸시되지 않은 커밋 없음", + "unpushedCommitsCount": "푸시되지 않은 커밋: {{value0}}", + "uncommittedChanges": "커밋되지 않은 변경 사항", + "terminalTabsCount": "터미널 탭: {{value0}}", + "editorTabsCount": "편집기 탭: {{value0}}", + "browserTabsCount": "브라우저 탭: {{value0}}", + "diffNotesCount": "Diff 메모: {{value0}}", + "completedAgentsCount": "완료된 에이전트: {{value0}}", + "contextCount": "문맥: {{value0}}", + "mainWorkspaceBlocker": "기본 워크스페이스", + "folderProjectBlocker": "폴더 프로젝트", + "pinnedBlocker": "고정됨", + "activeWorkspaceBlocker": "활성 워크스페이스", + "runningTerminalBlocker": "실행 중인 터미널 프로세스", + "terminalLivenessUnknownBlocker": "터미널 상태 알 수 없음", + "dirtyEditorBufferBlocker": "저장되지 않은 편집기 버퍼", + "volatileLocalContextBlocker": "휘발성 로컬 문맥", + "recentVisibleContextBlocker": "최근 방문한 탭", + "liveAgentBlocker": "활성 에이전트", + "sshDisconnectedBlocker": "원격을 사용할 수 없음", + "gitStatusErrorBlocker": "Git 상태를 사용할 수 없음", + "dirtyFilesBlocker": "변경된 파일", + "unknownBaseBlocker": "푸시되지 않은 커밋을 확인할 수 없음", + "dismissedBlocker": "무시됨" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 서버의 프로젝트", "automationCreated": "자동화로 생성됨", "branchIdentity": "브랜치", - "branchFolderPathIdentity": "브랜치 또는 폴더 경로" + "branchFolderPathIdentity": "브랜치 또는 폴더 경로", + "ef18787206": "삭제 대기 중" }, "WorktreeCardAgents": { "1b0a156717": "에이전트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a0b21163a78..cda02d6db46 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "工作区不再存在。" + "9d6e531da6": "工作区不再存在。", + "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "上下文", "sortBy": "排序依据", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "删除继续期间,你可以关闭此窗口并稍后回来。", + "4c2990886e": "已删除 {{value0}}/{{value1}}", + "86ba852118": "{{value0}},{{value1}} 个失败", + "7b7bde5181": "到目前为止已检查工作区:{{value0}}", + "deletingCount": "正在删除工作区:{{value0}}", + "deleteCount": "删除工作区:{{value0}}?", + "selectedForDeletionCount": "已选择删除:{{value0}}", + "deleteButtonCount": "删除 {{value0}}", + "archivedStatus": "已归档", + "readyStatus": "就绪" + }, + "backgroundRemoval": { + "removed": "已删除工作区:{{value0}}", + "failed": "未删除工作区:{{value0}}", + "error": "工作区清理失败", + "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", + "timedOut": "删除 {{value0}} 的时间比预期长。它将在后台继续运行。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "提交", + "contextLabel": "上下文", + "flagsLabel": "标记", + "collapseDetails": "收起详情", + "expandDetails": "展开详情", + "cleanGit": "Git 干净", + "dirtyGit": "Git 有更改", + "unpushedCommits": "未推送提交", + "gitUnknown": "Git 未知", + "gitStatusUnknown": "Git 状态未知", + "noUnpushedCommits": "没有未推送提交", + "unpushedCommitsCount": "未推送提交:{{value0}}", + "uncommittedChanges": "未提交的更改", + "terminalTabsCount": "终端标签页:{{value0}}", + "editorTabsCount": "编辑器标签页:{{value0}}", + "browserTabsCount": "浏览器标签页:{{value0}}", + "diffNotesCount": "Diff 备注:{{value0}}", + "completedAgentsCount": "已完成智能体:{{value0}}", + "contextCount": "上下文:{{value0}}", + "mainWorkspaceBlocker": "主工作区", + "folderProjectBlocker": "文件夹项目", + "pinnedBlocker": "已固定", + "activeWorkspaceBlocker": "活动工作区", + "runningTerminalBlocker": "正在运行的终端进程", + "terminalLivenessUnknownBlocker": "终端状态未知", + "dirtyEditorBufferBlocker": "未保存的编辑器缓冲区", + "volatileLocalContextBlocker": "易变本地上下文", + "recentVisibleContextBlocker": "最近访问的标签页", + "liveAgentBlocker": "活动智能体", + "sshDisconnectedBlocker": "远程不可用", + "gitStatusErrorBlocker": "Git 状态不可用", + "dirtyFilesBlocker": "已更改文件", + "unknownBaseBlocker": "无法验证未推送提交", + "dismissedBlocker": "已忽略" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 服务器上的项目", "automationCreated": "由自动化创建", "branchIdentity": "分支", - "branchFolderPathIdentity": "分支或文件夹路径" + "branchFolderPathIdentity": "分支或文件夹路径", + "ef18787206": "已排队删除" }, "WorktreeCardAgents": { "1b0a156717": "智能体" diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index dd4b531fbd8..e8f38d53cfe 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -216,6 +216,37 @@ describe('removeWorktree cascade', () => { }) }) + it('can suppress preserved branch warning toasts for batched cleanup removal', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'Review cleanup' + }) + ] + } + }) + + const result = await store + .getState() + .removeWorktree(worktreeId, false, { suppressPreservedBranchToast: true }) + + expect(result).toEqual({ + ok: true, + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + expect(toast.warning).not.toHaveBeenCalled() + }) + it('sets delete state with dirty/untracked error and canForceDelete=true on failure', async () => { const store = createTestStore() const worktreeId = 'repo1::/path/wt1' @@ -270,6 +301,44 @@ describe('removeWorktree cascade', () => { }) }) + it('marks multiple worktrees queued for deletion in one optimistic state update', () => { + const store = createTestStore() + const first = 'repo1::/path/wt1' + const second = 'repo1::/path/wt2' + + seedStore(store, { + deleteStateByWorktreeId: { + [first]: { isDeleting: false, error: 'old failure', canForceDelete: true } + } + }) + + store.getState().markWorktreesQueuedForDeletion([first, second, first]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [first]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false }, + [second]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + + it('keeps active deletion state when cleanup queues stale rows', () => { + const store = createTestStore() + const active = 'repo1::/path/deleting' + const queued = 'repo1::/path/queued' + + seedStore(store, { + deleteStateByWorktreeId: { + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false } + } + }) + + store.getState().markWorktreesQueuedForDeletion([active, queued]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false }, + [queued]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + it('offers force delete for Electron-wrapped local dirty preflight errors', async () => { const store = createTestStore() const worktreeId = 'repo1::/workspace/feature-wt' diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts new file mode 100644 index 00000000000..745e1a32298 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + installWorkspaceCleanupApi, + makeCandidate, + makeState +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup removal and protection', () => { + it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => { + let activePreflights = 0 + let maxActivePreflights = 0 + let activeDeletes = 0 + let maxActiveDeletes = 0 + const deleteOrder: string[] = [] + const candidates = [ + makeCandidate({ + worktreeId: 'repo-a::/repo/parent', + repoId: 'repo-a', + path: '/repo/parent', + displayName: 'parent' + }), + makeCandidate({ + worktreeId: 'repo-b::/repo/parent/child', + repoId: 'repo-b', + path: '/repo/parent/child', + displayName: 'child' + }), + makeCandidate({ + worktreeId: 'repo-c::/other', + repoId: 'repo-c', + path: '/other', + displayName: 'other', + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, + blockers: ['git-status-error'] + }) + ] + const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) + const scan = vi.fn(async (args?: { worktreeId?: string }) => { + activePreflights += 1 + maxActivePreflights = Math.max(maxActivePreflights, activePreflights) + await new Promise((resolve) => setTimeout(resolve, 5)) + activePreflights -= 1 + return { + scannedAt: NOW, + candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [], + errors: [] + } satisfies WorkspaceCleanupScanResult + }) + installWorkspaceCleanupApi(scan) + + const removeWorktree = vi.fn(async (worktreeId: string) => { + deleteOrder.push(worktreeId) + activeDeletes += 1 + maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes) + await new Promise((resolve) => setTimeout(resolve, 5)) + activeDeletes -= 1 + return { ok: true as const } + }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] } + } as Partial) + + await expect( + store + .getState() + .removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId)) + ).resolves.toEqual({ + removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)), + failures: [] + }) + + expect(maxActivePreflights).toBeGreaterThan(1) + expect(maxActiveDeletes).toBe(1) + expect(deleteOrder).toEqual([ + 'repo-b::/repo/parent/child', + 'repo-a::/repo/parent', + 'repo-c::/other' + ]) + expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true, { + suppressPreservedBranchToast: true + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) + }) + + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ activeWorktreeId: WORKTREE_ID }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('keeps a viewed active workspace visible but not removable', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('active-workspace') + }) + + it('does not preserve the cleanup view exception after the row changes', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate({ fingerprint: 'fingerprint-2' })], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('protects recently visible old workspaces with open context', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + openFiles: [ + { + id: 'file-1', + worktreeId: WORKTREE_ID, + filePath: '/tmp/old-workspace/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + isDirty: false + } + ] as AppState['openFiles'], + lastVisitedAtByWorktreeId: { + [WORKTREE_ID]: Date.now() + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('recent-visible-context') + }) + + it('uses current renderer state after async delete preflight scan resolves', async () => { + let resolveScan: (value: WorkspaceCleanupScanResult) => void + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan: vi.fn( + (): Promise => + new Promise((resolve) => { + resolveScan = resolve + }) + ), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + store.setState({ activeWorktreeId: WORKTREE_ID }) + resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] }) + + await expect(removal).resolves.toEqual({ + removedIds: [WORKTREE_ID], + failures: [] + }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) + }) + + it('defers git checks for locally active workspaces on initial scans', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const store = createCleanupTestStore() + store.setState({ + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + 'repo1::/tmp/terminal-workspace': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledWith( + { + skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) + }, + expect.any(Function) + ) + }) + + it('does not defer git checks for focused remove preflights', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) + + await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + + expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID }) + }) + + it('lets explicitly selected not-suggested workspaces reach the removal path', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: true + }) + } + } + } + + const store = createCleanupTestStore(removeWorktree) + + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( + { + removedIds: [WORKTREE_ID], + failures: [] + } + ) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) + }) + + it('fails a queued removal that now needs a force the user never approved', async () => { + const approvedCandidate = makeCandidate() + const dirtySinceConfirmation = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [dirtySinceConfirmation], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ + removedIds: [], + failures: [ + { + worktreeId: WORKTREE_ID, + displayName: 'old-workspace', + message: 'Workspace changed after confirmation. Refresh to review it before removing.' + } + ] + }) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('still force-removes rows whose approved candidate already carried git risk', async () => { + const approvedCandidate = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [approvedCandidate], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, { + suppressPreservedBranchToast: true + }) + }) + + it('protects old workspaces when an agent process is still foregrounded', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn().mockResolvedValue(true), + getForegroundProcess: vi.fn().mockResolvedValue('codex') + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) + + it('does not let an idle title in another tab mask a running agent process', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'), + getForegroundProcess: vi.fn(async (ptyId: string) => + ptyId === 'pty-running' ? 'codex' : 'zsh' + ) + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + { id: 'tab-running', title: 'zsh' }, + { id: 'tab-idle', title: 'Codex done' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { + 'tab-running': ['pty-running'], + 'tab-idle': ['pty-idle'] + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts new file mode 100644 index 00000000000..f38a10f86e5 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup scan progress', () => { + it('joins duplicate broad cleanup scans', async () => { + const pending = deferred() + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store.getState().scanWorkspaceCleanup() + const second = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(true) + + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + pending.resolve(result) + + await expect(Promise.all([first, second])).resolves.toEqual([result, result]) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { + const pending = deferred() + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + let joinedScan: Promise | null = null + + const unsubscribe = store.subscribe((state, previousState) => { + if ( + previousState.workspaceCleanupLoading && + !state.workspaceCleanupLoading && + joinedScan === null + ) { + joinedScan = state.scanWorkspaceCleanup() + } + }) + + const firstScan = store.getState().scanWorkspaceCleanup() + pending.resolve(result) + + await expect(firstScan).resolves.toEqual(result) + await expect(joinedScan).resolves.toEqual(result) + unsubscribe() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('shows scanned cleanup candidates before the final broad scan resolves', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [partialCandidate], + errors: [] + }) + + expect(store.getState().workspaceCleanupLoading).toBe(true) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 2 + }) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) + + pending.resolve({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + + await expect(scanPromise).resolves.toEqual({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 2, + totalWorktreeCount: 2 + }) + }) + + it('does not re-probe previously enriched rows during cumulative progress updates', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockResolvedValue(false) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + }) + + it('updates count-only append progress without replacing existing candidate rows', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [candidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend) + + pending.resolve({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + await scanPromise + }) + + it('keeps append progress rows when terminal enrichment resolves out of order', async () => { + const pending = deferred() + const terminalProbe = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + await Promise.resolve() + expect(store.getState().workspaceCleanupProgress).toBeNull() + + terminalProbe.resolve(false) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/later']) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + }) + + it('publishes the final scan result without waiting for queued progress', async () => { + const pending = deferred() + const terminalProbe = deferred() + let scanSettled = false + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store + .getState() + .scanWorkspaceCleanup() + .finally(() => { + scanSettled = true + }) + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + pending.resolve({ + scannedAt: NOW, + candidates: [finalCandidate], + errors: [] + }) + await scanPromise + expect(scanSettled).toBe(true) + + terminalProbe.resolve(false) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/final']) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('ignores stale progress without replacing the active scan progress queue', async () => { + const firstPending = deferred() + const secondPending = deferred() + const terminalProbe = deferred() + const progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] = [] + let secondScanSettled = false + const firstCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/first' }) + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((args, progressCallback) => { + progressCallbacks.push(progressCallback) + return args?.skipGitWorktreeIds?.includes('second') + ? secondPending.promise + : firstPending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const firstScan = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] }) + const secondScan = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] }) + .finally(() => { + secondScanSettled = true + }) + + progressCallbacks[1]?.({ + scanId: 'scan-2', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + progressCallbacks[0]?.({ + scanId: 'scan-1', + scannedAt: NOW - 1, + scannedWorktreeCount: 1, + totalWorktreeCount: 1, + candidates: [firstCandidate], + errors: [], + candidateMode: 'append' + }) + + secondPending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, finalCandidate], + errors: [] + }) + await Promise.resolve() + expect(secondScanSettled).toBe(false) + + terminalProbe.resolve(false) + await secondScan + firstPending.resolve({ scannedAt: NOW - 1, candidates: [firstCandidate], errors: [] }) + await firstScan + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/final']) + }) + + it('does not let an in-flight broad scan revive removed cleanup rows', async () => { + const firstBroadScan = deferred() + const secondBroadScan = deferred() + const candidate = makeCandidate() + const refreshedCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/refreshed' }) + const broadScans = [firstBroadScan, secondBroadScan] + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + } + const nextBroadScan = broadScans.shift() + if (!nextBroadScan) { + throw new Error('unexpected broad scan') + } + return nextBroadScan.promise + }) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW - 1, candidates: [candidate], errors: [] } + } as Partial) + + const pendingRefresh = store.getState().scanWorkspaceCleanup() + await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + const replacementRefresh = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(3) + + secondBroadScan.resolve({ scannedAt: NOW, candidates: [refreshedCandidate], errors: [] }) + await expect(replacementRefresh).resolves.toMatchObject({ + candidates: [refreshedCandidate] + }) + + firstBroadScan.resolve({ scannedAt: NOW - 1, candidates: [candidate], errors: [] }) + await pendingRefresh + + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate]) + }) + + it('does not join broad cleanup scans with different explicit args', async () => { + const firstPending = deferred() + const secondPending = deferred() + const firstResult = { + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], + errors: [] + } + const secondResult = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], + errors: [] + } + const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => + args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') + ? firstPending.promise + : secondPending.promise + ) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) + const second = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) + + expect(scan).toHaveBeenCalledTimes(2) + secondPending.resolve(secondResult) + await second + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + + firstPending.resolve(firstResult) + await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + }) + + it('keeps stale cleanup results visible after a broad refresh failure', async () => { + const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi + .fn() + .mockResolvedValueOnce(previous) + .mockRejectedValueOnce(new Error('scan failed')) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + await store.getState().scanWorkspaceCleanup() + await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') + + expect(store.getState().workspaceCleanupScan).toMatchObject(previous) + expect(store.getState().workspaceCleanupError).toBe('scan failed') + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('keeps focused cleanup preflight scans separate from broad scans', async () => { + const broad = deferred() + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + } satisfies WorkspaceCleanupScanResult) + } + return broad.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const broadScan = store.getState().scanWorkspaceCleanup() + const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) + + expect(scan).toHaveBeenCalledTimes(2) + expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) + expect(store.getState().workspaceCleanupScan).toBeNull() + + broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) + await broadScan + expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts new file mode 100644 index 00000000000..8e0f0ca83dc --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand' +import { vi } from 'vitest' +import type { AppState } from '../types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' + +export const WORKTREE_ID = 'repo1::/tmp/old-workspace' +export const NOW = 1_700_000_000_000 + +export function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: WORKTREE_ID, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId: null, + displayName: 'old-workspace', + branch: 'old-workspace', + path: '/tmp/old-workspace', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + }, + fingerprint: 'fingerprint-1', + ...overrides + } +} + +export function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + workspaceCleanupDismissals: {}, + workspaceCleanupViewedCandidates: {}, + ...overrides + } as AppState +} + +export function createCleanupTestStore(removeWorktree: ReturnType = vi.fn()) { + return create()( + (...a) => + ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + removeWorktree, + ...createWorkspaceCleanupSlice(...a) + }) as unknown as AppState + ) +} + +export function installWorkspaceCleanupApi(scan: ReturnType) { + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } +} + +export function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve + reject = innerReject + }) + return { promise, resolve, reject } +} diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts deleted file mode 100644 index 3a5199e2adc..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { create } from 'zustand' -import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '../types' -import type { - WorkspaceCleanupCandidate, - WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' -import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup' - -const WORKTREE_ID = 'repo1::/tmp/old-workspace' -const NOW = 1_700_000_000_000 - -function makeCandidate( - overrides: Partial = {} -): WorkspaceCleanupCandidate { - return { - worktreeId: WORKTREE_ID, - repoId: 'repo1', - repoName: 'Repo 1', - connectionId: null, - displayName: 'old-workspace', - branch: 'old-workspace', - path: '/tmp/old-workspace', - tier: 'ready', - selectedByDefault: true, - reasons: ['idle-clean'], - blockers: [], - lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, - localContext: { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: 0, - newestDiffCommentAt: null, - retainedDoneAgentCount: 0 - }, - git: { - clean: true, - upstreamAhead: 0, - upstreamBehind: 0, - checkedAt: NOW - }, - fingerprint: 'fingerprint-1', - ...overrides - } -} - -function makeState(overrides: Partial = {}): AppState { - return { - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - workspaceCleanupDismissals: {}, - workspaceCleanupViewedCandidates: {}, - ...overrides - } as AppState -} - -function createCleanupTestStore(removeWorktree = vi.fn()) { - return create()( - (...a) => - ({ - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - removeWorktree, - ...createWorkspaceCleanupSlice(...a) - }) as unknown as AppState - ) -} - -function installWorkspaceCleanupApi(scan: ReturnType) { - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } -} - -function deferred(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('workspace cleanup viewed rows', () => { - it('joins duplicate broad cleanup scans', async () => { - const pending = deferred() - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store.getState().scanWorkspaceCleanup() - const second = store.getState().scanWorkspaceCleanup() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(true) - - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - pending.resolve(result) - - await expect(Promise.all([first, second])).resolves.toEqual([result, result]) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { - const pending = deferred() - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - let joinedScan: Promise | null = null - - const unsubscribe = store.subscribe((state, previousState) => { - if ( - previousState.workspaceCleanupLoading && - !state.workspaceCleanupLoading && - joinedScan === null - ) { - joinedScan = state.scanWorkspaceCleanup() - } - }) - - const firstScan = store.getState().scanWorkspaceCleanup() - pending.resolve(result) - - await expect(firstScan).resolves.toEqual(result) - await expect(joinedScan).resolves.toEqual(result) - unsubscribe() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('shows scanned cleanup candidates before the final broad scan resolves', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) - const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [partialCandidate], - errors: [] - }) - - expect(store.getState().workspaceCleanupLoading).toBe(true) - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 1, - totalWorktreeCount: 2 - }) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) - - pending.resolve({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - - await expect(scanPromise).resolves.toEqual({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - expect(store.getState().workspaceCleanupLoading).toBe(false) - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 2, - totalWorktreeCount: 2 - }) - }) - - it('does not re-probe previously enriched rows during cumulative progress updates', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) - const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const hasChildProcesses = vi.fn().mockResolvedValue(false) - const getForegroundProcess = vi.fn().mockResolvedValue('zsh') - ;( - globalThis.window as unknown as { - api: { - pty?: { - hasChildProcesses: typeof hasChildProcesses - getForegroundProcess: typeof getForegroundProcess - } - } - } - ).api.pty = { hasChildProcesses, getForegroundProcess } - const store = createCleanupTestStore() - store.setState({ - tabsByWorktree: { - 'repo1::/tmp/terminal': [ - { id: 'tab-1', title: 'zsh' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [terminalCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) - }) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 2, - totalWorktreeCount: 2, - candidates: [laterCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - pending.resolve({ - scannedAt: NOW, - candidates: [terminalCandidate, laterCandidate], - errors: [] - }) - await scanPromise - - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - }) - - it('does not join broad cleanup scans with different explicit args', async () => { - const firstPending = deferred() - const secondPending = deferred() - const firstResult = { - scannedAt: NOW, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], - errors: [] - } - const secondResult = { - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], - errors: [] - } - const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => - args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') - ? firstPending.promise - : secondPending.promise - ) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) - const second = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) - - expect(scan).toHaveBeenCalledTimes(2) - secondPending.resolve(secondResult) - await second - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - - firstPending.resolve(firstResult) - await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - }) - - it('keeps stale cleanup results visible after a broad refresh failure', async () => { - const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi - .fn() - .mockResolvedValueOnce(previous) - .mockRejectedValueOnce(new Error('scan failed')) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - await store.getState().scanWorkspaceCleanup() - await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') - - expect(store.getState().workspaceCleanupScan).toMatchObject(previous) - expect(store.getState().workspaceCleanupError).toBe('scan failed') - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('keeps focused cleanup preflight scans separate from broad scans', async () => { - const broad = deferred() - const scan = vi.fn((args?: { worktreeId?: string }) => { - if (args?.worktreeId) { - return Promise.resolve({ - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: args.worktreeId })], - errors: [] - } satisfies WorkspaceCleanupScanResult) - } - return broad.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const broadScan = store.getState().scanWorkspaceCleanup() - const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) - - expect(scan).toHaveBeenCalledTimes(2) - expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) - expect(store.getState().workspaceCleanupScan).toBeNull() - - broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) - await broadScan - expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) - }) - - it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => { - let activePreflights = 0 - let maxActivePreflights = 0 - let activeDeletes = 0 - let maxActiveDeletes = 0 - const deleteOrder: string[] = [] - const candidates = [ - makeCandidate({ - worktreeId: 'repo-a::/repo/parent', - repoId: 'repo-a', - path: '/repo/parent', - displayName: 'parent' - }), - makeCandidate({ - worktreeId: 'repo-b::/repo/parent/child', - repoId: 'repo-b', - path: '/repo/parent/child', - displayName: 'child' - }), - makeCandidate({ - worktreeId: 'repo-c::/other', - repoId: 'repo-c', - path: '/other', - displayName: 'other', - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, - blockers: ['git-status-error'] - }) - ] - const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) - const scan = vi.fn(async (args?: { worktreeId?: string }) => { - activePreflights += 1 - maxActivePreflights = Math.max(maxActivePreflights, activePreflights) - await new Promise((resolve) => setTimeout(resolve, 5)) - activePreflights -= 1 - return { - scannedAt: NOW, - candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [], - errors: [] - } satisfies WorkspaceCleanupScanResult - }) - installWorkspaceCleanupApi(scan) - - const removeWorktree = vi.fn(async (worktreeId: string) => { - deleteOrder.push(worktreeId) - activeDeletes += 1 - maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes) - await new Promise((resolve) => setTimeout(resolve, 5)) - activeDeletes -= 1 - return { ok: true as const } - }) - const store = createCleanupTestStore(removeWorktree) - store.setState({ - workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] } - } as Partial) - - await expect( - store - .getState() - .removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId)) - ).resolves.toEqual({ - removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)), - failures: [] - }) - - expect(maxActivePreflights).toBeGreaterThan(1) - expect(maxActiveDeletes).toBe(1) - expect(deleteOrder).toEqual([ - 'repo-b::/repo/parent/child', - 'repo-a::/repo/parent', - 'repo-c::/other' - ]) - expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true) - expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) - }) - - it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ activeWorktreeId: WORKTREE_ID }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.blockers).toContain('active-workspace') - }) - - it('keeps a viewed active workspace visible but not removable', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - activeWorktreeId: WORKTREE_ID, - workspaceCleanupViewedCandidates: { - [WORKTREE_ID]: { - viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true - } - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('active-workspace') - }) - - it('does not preserve the cleanup view exception after the row changes', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate({ fingerprint: 'fingerprint-2' })], - makeState({ - activeWorktreeId: WORKTREE_ID, - workspaceCleanupViewedCandidates: { - [WORKTREE_ID]: { - viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true - } - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.blockers).toContain('active-workspace') - }) - - it('protects recently visible old workspaces with open context', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - openFiles: [ - { - id: 'file-1', - worktreeId: WORKTREE_ID, - filePath: '/tmp/old-workspace/src/app.ts', - relativePath: 'src/app.ts', - language: 'typescript', - isDirty: false - } - ] as AppState['openFiles'], - lastVisitedAtByWorktreeId: { - [WORKTREE_ID]: Date.now() - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('recent-visible-context') - }) - - it('uses current renderer state after async delete preflight scan resolves', async () => { - let resolveScan: (value: WorkspaceCleanupScanResult) => void - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - const store = createCleanupTestStore(removeWorktree) - - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan: vi.fn( - (): Promise => - new Promise((resolve) => { - resolveScan = resolve - }) - ), - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) - store.setState({ activeWorktreeId: WORKTREE_ID }) - resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] }) - - await expect(removal).resolves.toEqual({ - removedIds: [WORKTREE_ID], - failures: [] - }) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) - }) - - it('defers git checks for locally active workspaces on initial scans', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [], - errors: [] - } satisfies WorkspaceCleanupScanResult) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const store = createCleanupTestStore() - store.setState({ - activeWorktreeId: WORKTREE_ID, - tabsByWorktree: { - 'repo1::/tmp/terminal-workspace': [ - { id: 'tab-1', title: 'zsh' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - await store.getState().scanWorkspaceCleanup() - - expect(scan).toHaveBeenCalledWith( - { - skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) - }, - expect.any(Function) - ) - }) - - it('does not defer git checks for focused remove preflights', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [makeCandidate()], - errors: [] - } satisfies WorkspaceCleanupScanResult) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - const store = createCleanupTestStore(removeWorktree) - store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) - - await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) - - expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID }) - }) - - it('lets explicitly selected not-suggested workspaces reach the removal path', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [makeCandidate()], - errors: [] - } satisfies WorkspaceCleanupScanResult) - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: true - }) - } - } - } - - const store = createCleanupTestStore(removeWorktree) - - store.setState({ - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( - { - removedIds: [WORKTREE_ID], - failures: [] - } - ) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) - }) - - it('protects old workspaces when an agent process is still foregrounded', async () => { - ;(globalThis as { window: unknown }).window = { - api: { - pty: { - hasChildProcesses: vi.fn().mockResolvedValue(true), - getForegroundProcess: vi.fn().mockResolvedValue('codex') - } - } - } - - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('running-terminal') - }) - - it('does not let an idle title in another tab mask a running agent process', async () => { - ;(globalThis as { window: unknown }).window = { - api: { - pty: { - hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'), - getForegroundProcess: vi.fn(async (ptyId: string) => - ptyId === 'pty-running' ? 'codex' : 'zsh' - ) - } - } - } - - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - tabsByWorktree: { - [WORKTREE_ID]: [ - { id: 'tab-running', title: 'zsh' }, - { id: 'tab-idle', title: 'Codex done' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { - 'tab-running': ['pty-running'], - 'tab-idle': ['pty-idle'] - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('running-terminal') - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 502cead194a..52106424121 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -35,6 +35,12 @@ export type WorkspaceCleanupRemoveResult = { failures: WorkspaceCleanupFailure[] } +export type WorkspaceCleanupRemoveOptions = { + // Why: rows are removed long after the confirm click; the confirm-time + // candidate records how much git risk the user actually approved. + approvedCandidates?: readonly WorkspaceCleanupCandidate[] +} + type WorkspaceCleanupViewedCandidate = { viewedAt: number fingerprint: string @@ -55,7 +61,8 @@ export type WorkspaceCleanupSlice = { ) => Promise resetWorkspaceCleanupDismissals: () => Promise removeWorkspaceCleanupCandidates: ( - worktreeIds: readonly string[] + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions ) => Promise } @@ -78,10 +85,23 @@ let inFlightWorkspaceCleanupScan: { promise: Promise } | null = null let latestWorkspaceCleanupScanToken = 0 +let finalizedWorkspaceCleanupScanToken = 0 +let workspaceCleanupProgressQueue: { + scanToken: number + promise: Promise +} | null = null let workspaceCleanupEnrichmentCache: { scanToken: number entries: Map } | null = null +// Why: cleanup progress can append thousands of rows; keep one scan-local +// index so each streamed row does not rebuild a map of every previous row. +let workspaceCleanupProgressCandidateIndex: { + scanToken: number + scanId: string + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} | null = null const SHELL_PROCESS_NAMES = new Set([ 'bash', @@ -160,10 +180,13 @@ export const createWorkspaceCleanupSlice: StateCreator { const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { - void applyWorkspaceCleanupProgress(progress, scanToken, get, set) + enqueueWorkspaceCleanupProgress(progress, scanToken, get, set) }) const enriched = await enrichWorkspaceCleanupCandidatesForScan( scan.candidates, @@ -172,10 +195,13 @@ export const createWorkspaceCleanupSlice: StateCreator { + removeWorkspaceCleanupCandidates: async (worktreeIds, options) => { const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const approvedCandidatesByWorktreeId = new Map( + (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) + ) const preflights = await mapWithConcurrency( worktreeIds, WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY, - (worktreeId) => preflightWorkspaceCleanupCandidate(worktreeId, get) + (worktreeId) => + preflightWorkspaceCleanupCandidate( + worktreeId, + get, + approvedCandidatesByWorktreeId.get(worktreeId) + ) ) const candidatesToRemove: WorkspaceCleanupCandidate[] = [] @@ -290,7 +324,10 @@ export const createWorkspaceCleanupSlice: StateCreator b.path.length - a.path.length)) { const result = await get().removeWorktree( candidate.worktreeId, - shouldForceWorkspaceCleanupRemoval(candidate) + shouldForceWorkspaceCleanupRemoval(candidate), + // Why: cleanup reports outcomes in its own summary toasts; per-row + // preserved-branch warnings would stack one toast per removed row. + { suppressPreservedBranchToast: true } ) if (result.ok) { removedIds.push(candidate.worktreeId) @@ -304,8 +341,10 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { + invalidateWorkspaceCleanupScanProgress() const removedIdSet = new Set(removedIds) set((state) => ({ + workspaceCleanupLoading: false, workspaceCleanupScan: state.workspaceCleanupScan ? { ...state.workspaceCleanupScan, @@ -327,6 +366,43 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string { }) } +function invalidateWorkspaceCleanupScanProgress(): void { + latestWorkspaceCleanupScanToken += 1 + finalizedWorkspaceCleanupScanToken = 0 + inFlightWorkspaceCleanupScan = null + workspaceCleanupProgressQueue = null + workspaceCleanupEnrichmentCache = null + workspaceCleanupProgressCandidateIndex = null +} + +function enqueueWorkspaceCleanupProgress( + progress: WorkspaceCleanupScanProgress, + scanToken: number, + getState: () => AppState, + setState: ( + partial: Partial | ((state: AppState) => Partial), + replace?: false + ) => void +): void { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const previous = + workspaceCleanupProgressQueue?.scanToken === scanToken + ? workspaceCleanupProgressQueue.promise + : Promise.resolve() + const promise = previous + .catch(() => undefined) + .then(() => applyWorkspaceCleanupProgress(progress, scanToken, getState, setState)) + .catch((error: unknown) => { + console.error('Workspace cleanup progress update failed', error) + }) + workspaceCleanupProgressQueue = { scanToken, promise } +} + async function applyWorkspaceCleanupProgress( progress: WorkspaceCleanupScanProgress, scanToken: number, @@ -336,6 +412,12 @@ async function applyWorkspaceCleanupProgress( replace?: false ) => void ): Promise { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } const state = getState() const previousCandidates = progress.candidateMode === 'append' && @@ -347,11 +429,23 @@ async function applyWorkspaceCleanupProgress( state, scanToken ) - const candidates = - progress.candidateMode === 'append' - ? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates) - : enrichedProgressCandidates - if (scanToken !== latestWorkspaceCleanupScanToken) { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const candidates = mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates: enrichedProgressCandidates, + progress, + scanToken + }) + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + workspaceCleanupProgressCandidateIndex = null return } setState((state) => { @@ -387,30 +481,74 @@ async function enrichWorkspaceCleanupCandidatesForScan( ) } -function appendWorkspaceCleanupProgressCandidates( - previousCandidates: readonly WorkspaceCleanupCandidate[], +function mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates, + progress, + scanToken +}: { + previousCandidates: readonly WorkspaceCleanupCandidate[] nextCandidates: readonly WorkspaceCleanupCandidate[] -): WorkspaceCleanupCandidate[] { - if (nextCandidates.length === 0) { - return [...previousCandidates] + progress: WorkspaceCleanupScanProgress + scanToken: number +}): WorkspaceCleanupCandidate[] { + if (progress.candidateMode !== 'append') { + workspaceCleanupProgressCandidateIndex = null + return [...nextCandidates] } - const indexesByWorktreeId = new Map( - previousCandidates.map((candidate, index) => [candidate.worktreeId, index]) + if (nextCandidates.length === 0) { + return previousCandidates as WorkspaceCleanupCandidate[] + } + + const indexCache = getWorkspaceCleanupProgressCandidateIndex( + previousCandidates, + progress.scanId, + scanToken ) - const merged = [...previousCandidates] + const merged = [...indexCache.candidates] for (const candidate of nextCandidates) { - const existingIndex = indexesByWorktreeId.get(candidate.worktreeId) + const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId) if (existingIndex === undefined) { - indexesByWorktreeId.set(candidate.worktreeId, merged.length) + indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length) merged.push(candidate) continue } merged[existingIndex] = candidate } + workspaceCleanupProgressCandidateIndex = { + scanToken, + scanId: progress.scanId, + candidates: merged, + indexesByWorktreeId: indexCache.indexesByWorktreeId + } return merged } +function getWorkspaceCleanupProgressCandidateIndex( + candidates: readonly WorkspaceCleanupCandidate[], + scanId: string, + scanToken: number +): { + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} { + if ( + workspaceCleanupProgressCandidateIndex?.scanToken === scanToken && + workspaceCleanupProgressCandidateIndex.scanId === scanId && + workspaceCleanupProgressCandidateIndex.candidates === candidates + ) { + return workspaceCleanupProgressCandidateIndex + } + + return { + candidates: [...candidates], + indexesByWorktreeId: new Map( + candidates.map((candidate, index) => [candidate.worktreeId, index]) + ) + } +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -681,7 +819,8 @@ function applyDismissal( async function preflightWorkspaceCleanupCandidate( worktreeId: string, - getState: () => AppState + getState: () => AppState, + approvedCandidate?: WorkspaceCleanupCandidate ): Promise< | { ok: true; candidate: WorkspaceCleanupCandidate } | { ok: false; failure: WorkspaceCleanupFailure } @@ -715,6 +854,26 @@ async function preflightWorkspaceCleanupCandidate( } } } + // Why: this row may be removed minutes after the confirm click. If it now + // needs a force removal the user never approved (new dirt, unpushed work, + // or a git error since confirmation), fail it instead of force-deleting. + if ( + approvedCandidate && + shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate) + ) { + return { + ok: false, + failure: { + worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', + 'Workspace changed after confirmation. Refresh to review it before removing.' + ) + } + } + } return { ok: true, candidate } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 85efa92dcc1..1f2ce461ba2 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -30,6 +30,7 @@ export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { isDeleting: boolean + phase?: 'deleting' | 'queued' error: string | null canForceDelete: boolean } @@ -181,9 +182,10 @@ export type WorktreeSlice = { // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) // for workspaces pinned to a removed/disconnected SSH host. Reuses the same // renderer-side teardown/purge as a normal remove. - options?: { mode?: 'remove' | 'forget-local' } + options?: { mode?: 'remove' | 'forget-local'; suppressPreservedBranchToast?: boolean } ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void + markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( worktreeId: string, branchName: string, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 36d70b5d319..d6fb11a315b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3151,6 +3151,7 @@ export const createWorktreeSlice: StateCreator ...s.deleteStateByWorktreeId, [worktreeId]: { isDeleting: true, + phase: 'deleting', error: null, canForceDelete: false } @@ -3508,7 +3509,7 @@ export const createWorktreeSlice: StateCreator // prune effect cannot be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch - if (preservedBranch) { + if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) }) @@ -3548,6 +3549,31 @@ export const createWorktreeSlice: StateCreator } nextDeleteState[worktreeId] = { isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false + } + changed = true + } + return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + }) + }, + + markWorktreesQueuedForDeletion: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + set((s) => { + const nextDeleteState = { ...s.deleteStateByWorktreeId } + let changed = false + for (const worktreeId of new Set(worktreeIds)) { + const current = nextDeleteState[worktreeId] + if (current?.isDeleting && current.error === null && !current.canForceDelete) { + continue + } + nextDeleteState[worktreeId] = { + isDeleting: true, + phase: 'queued', error: null, canForceDelete: false } From 5b1b8cc61f5f984048d60b2c52469e78e4aa195a Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 08:08:35 +0800 Subject: [PATCH 13/25] fix(preflight): resolve WSL/SSH agent paths past shell aliases (#7867) * fix(preflight): resolve WSL/SSH agent paths past shell aliases LeanCTX and similar tools wrap claude/codex as interactive shell aliases. command -v then returns alias text, which fails absolute-path detection and hides installed agents (#7816). Prefer bash type -P, then zsh type -p, then command -v for dash/sh fallback in WSL agent discovery, WSL isCommandOnPath, and remote relay probes. * fix(preflight): harden alias-safe PATH lookup chain Require non-empty results between type -P, type -p, and command -v so bash type -p empty success cannot skip later lookups. * fix(preflight): resolve agent executables directly from PATH --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/ipc/preflight-command-exec.test.ts | 56 ++++ src/main/ipc/preflight-command-exec.ts | 23 +- .../ipc/preflight-wsl-agent-detection.test.ts | 16 + src/main/ipc/preflight-wsl-agent-detection.ts | 13 +- src/relay/preflight-handler.test.ts | 43 ++- src/relay/preflight-handler.ts | 6 +- src/shared/posix-command-path-lookup.test.ts | 312 ++++++++++++++++++ src/shared/posix-command-path-lookup.ts | 65 ++++ 8 files changed, 503 insertions(+), 31 deletions(-) create mode 100644 src/main/ipc/preflight-command-exec.test.ts create mode 100644 src/shared/posix-command-path-lookup.test.ts create mode 100644 src/shared/posix-command-path-lookup.ts diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts new file mode 100644 index 00000000000..204b8a5f6e1 --- /dev/null +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' + +const { runPreflightCommandInWslMock } = vi.hoisted(() => ({ + runPreflightCommandInWslMock: vi.fn() +})) + +vi.mock('./preflight-wsl-command', () => ({ + runPreflightCommandInWsl: runPreflightCommandInWslMock +})) + +import { isCommandOnPath } from './preflight-command-exec' + +describe('isCommandOnPath', () => { + const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__' + + beforeEach(() => { + runPreflightCommandInWslMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the shared literal lookup and accepts a sentinel-prefixed POSIX path', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ + stdout: `shell startup chatter\n${sentinel}/home/user/.local/bin/codex\n`, + stderr: '' + }) + + const found = await isCommandOnPath('codex', { distro: 'Ubuntu' }) + + expect(found).toBe(true) + expect(runPreflightCommandInWslMock).toHaveBeenCalledOnce() + const [, command] = runPreflightCommandInWslMock.mock.calls[0] as [{ distro: string }, string] + expect(command).toContain( + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) + ) + expect(command).toContain( + ['if [ -n "$resolved" ]; then', `printf '${sentinel}%s\\n' "$resolved"`, 'fi'].join('\n') + ) + }) + + it.each([ + ['/absolute/startup/chatter', false], + [`${sentinel}relative/path`, false], + ['codex', false], + ["alias codex='codex --wrapped'", false] + ])('parses WSL lookup output %s as available: %s', async (stdout, expected) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ stdout: `${stdout}\n`, stderr: '' }) + + await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected) + }) +}) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index b3c3ac990b7..b5fa8dee40a 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,12 +1,14 @@ import { execFile } from 'node:child_process' import path from 'node:path' import { promisify } from 'node:util' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildLocalPreflightEnv } from './preflight-local-env' import { runPreflightCommandInWsl } from './preflight-wsl-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +const WSL_COMMAND_PATH_SENTINEL = '__ORCA_PREFLIGHT_COMMAND_PATH__' export type PreflightCommandResult = { stdout: string; stderr: string } @@ -81,8 +83,27 @@ export async function isCommandOnPath( const finder = process.platform === 'win32' ? 'where' : 'which' try { const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + ? // Why: preflight must validate the executable on PATH, not a shell alias or function. + await execCommandInWsl( + wslTarget, + [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', + `printf '${WSL_COMMAND_PATH_SENTINEL}%s\\n' "$resolved"`, + 'fi' + ].join('\n') + ) : await execLocalPreflightCommand(finder, [command]) + if (wslTarget) { + // Why: WSL startup chatter can contain unrelated absolute paths. + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(WSL_COMMAND_PATH_SENTINEL)) + .map((line) => line.slice(WSL_COMMAND_PATH_SENTINEL.length)) + .some((line) => path.posix.isAbsolute(line)) + } + return stdout .split(/\r?\n/) .map((line) => line.trim()) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 5c544ef5c63..a1f9d7cfda5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -16,6 +16,8 @@ vi.mock('child_process', () => { }) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' function lastShCommandPayload(): string { const call = execFileAsyncMock.mock.calls.at(-1) @@ -47,6 +49,20 @@ describe('detectWslCommandsOnPath', () => { expect(payload).toContain('fi\ndone') }) + it('uses the shared alias- and function-neutral PATH lookup', async () => { + execFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex']) + + const payload = lastShCommandPayload() + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + expect(payload).toContain(escapeWslShCommandForWindows(lookupScript)) + expect(payload).not.toContain('type -P') + }) + it('parses detected commands from prefixed stdout', async () => { execFileAsyncMock.mockResolvedValue({ stdout: diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index ed7d3f5399b..be1f216ed9c 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildWslLoginShellCommand, escapeWslShCommandForWindows @@ -24,13 +25,15 @@ export async function detectWslCommandsOnPath( } const commandList = uniqueCommands.map(shellQuote).join(' ') - // Why: join with newlines, not spaces. zsh treats `fi done` as a parse error - // (it needs a separator before `done`); the login shell may be zsh, so a - // space-joined script silently fails for every agent. Newlines are valid - // statement separators in every POSIX shell and zsh. + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + // Newlines keep the loop valid in zsh and every POSIX shell used here. const script = [ `for cmd in ${commandList}; do`, - 'if resolved=$(command -v "$cmd" 2>/dev/null); then', + lookupScript, + 'if [ -n "$resolved" ]; then', `printf '${WSL_AGENT_DETECTION_PREFIX}%s\\t%s\\n' "$cmd" "$resolved";`, 'fi', 'done' diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 8833ddcb97f..80bc2ba490a 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() @@ -38,7 +39,8 @@ function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { return [ mode, [ - `if resolved=$(command -v ${command} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', 'fi' ].join('\n') @@ -77,14 +79,14 @@ describe('buildCommandLookupSpec', () => { it('falls back to sh for POSIX probes without a configured shell', () => { expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: lookupArgs("'codex'") + args: lookupArgs('codex') }) }) it('uses the configured remote shell for POSIX probes', () => { expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ file: '/bin/zsh', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -93,7 +95,7 @@ describe('buildCommandLookupSpec', () => { buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') ).toEqual({ file: '/bin/bash', - args: lookupArgs("'agent'\\''cli'", '-ilc') + args: lookupArgs("agent'cli", '-ilc') }) }) }) @@ -101,8 +103,8 @@ describe('buildCommandLookupSpec', () => { describe('buildCommandLookupSpecs', () => { it('falls back to inherited PATH after a trusted configured POSIX shell', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ - { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) @@ -115,15 +117,15 @@ describe('buildCommandLookupSpecs', () => { '/opt/homebrew/bin/zsh' ) ).toEqual([ - { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/opt/homebrew/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('allows conservative system shell paths when account lookup is unavailable', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ file: '/usr/bin/bash', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -136,14 +138,14 @@ describe('buildCommandLookupSpecs', () => { it('ignores untrusted temp shell paths even when the basename is supported', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('ignores untrusted home-bin shell paths even when the basename is supported', () => { expect( buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') - ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + ).toEqual([{ file: '/bin/sh', args: lookupArgs('codex') }]) }) }) @@ -160,17 +162,12 @@ describe('isCommandOnPathForRelay', () => { accountLoginShell: '/bin/zsh' }) ).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenNthCalledWith( - 1, - '/bin/zsh', - lookupArgs("'codex'", '-ilc'), - { - encoding: 'utf-8', - env: expect.objectContaining({ SHELL: '/bin/zsh' }), - timeout: 5000 - } - ) - expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenNthCalledWith(1, '/bin/zsh', lookupArgs('codex', '-ilc'), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/bin/zsh' }), timeout: 5000 @@ -203,7 +200,7 @@ describe('isCommandOnPathForRelay', () => { }) ).resolves.toBe(true) expect(execFileAsyncMock).toHaveBeenCalledTimes(1) - expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/tmp/zsh' }), timeout: 5000 diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 27e7713343c..101c796137c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -7,6 +7,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const execFileAsync = promisify(execFile) @@ -219,9 +220,10 @@ function buildPosixCommandLookupSpec(command: string, shell: string): CommandLoo } function buildShCommandLookupScript(command: string): string { - const quotedCommand = shellQuote(command) + // Why: login shells may define aliases or functions that mask the PATH executable. return [ - `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, 'fi' ].join('\n') diff --git a/src/shared/posix-command-path-lookup.test.ts b/src/shared/posix-command-path-lookup.test.ts new file mode 100644 index 00000000000..1ec2a398d32 --- /dev/null +++ b/src/shared/posix-command-path-lookup.test.ts @@ -0,0 +1,312 @@ +import { execFileSync } from 'node:child_process' +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildPosixCommandPathLookupScript } from './posix-command-path-lookup' +import { buildWslLoginShellCommand, escapeWslShCommandForWindows } from './wsl-login-shell-command' + +type ShellCase = { + name: string + path: string | null +} + +const isWindows = process.platform === 'win32' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null +const shellCases: ShellCase[] = [ + { name: 'sh', path: executablePath(['/bin/sh']) }, + { name: 'bash', path: executablePath(['/bin/bash', '/usr/bin/bash']) }, + { name: 'zsh', path: executablePath(['/bin/zsh', '/usr/bin/zsh']) }, + { name: 'dash', path: executablePath(['/bin/dash', '/usr/bin/dash']) } +] + +describe('buildPosixCommandPathLookupScript', () => { + for (const shell of shellCases) { + it.skipIf(isWindows || shell.path === null)( + `resolves without mutating alias and function masks in ${shell.name}`, + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"`, + `alias ${commandName} >/dev/null`, + `unalias ${commandName}`, + `${commandName}` + ].join('\n') + + const resolved = execFileSync(shell.path!, ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }) + .trim() + .split('\n') + + expect(isAbsolute(resolved[0])).toBe(true) + expect(realpathSync(resolved[0])).toBe(realpathSync(process.execPath)) + expect(resolved[1]).toBe('masked-function') + } + ) + } + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'resolves a command held in a validated shell variable', + () => { + const commandName = basename(process.execPath) + const script = [ + `cmd='${commandName}'`, + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }).trim() + + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/bash', '/usr/bin/bash']) === null)( + 'resolves past a readonly bash function mask', + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `readonly -f ${commandName}`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync( + executablePath(['/bin/bash', '/usr/bin/bash'])!, + ['-c', script], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + } + ).trim() + + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'prefers the first external executable even when its name is a shell builtin', + () => { + withExecutableFixture('printf', (directory, executable, root) => { + const secondDirectory = join(root, 'second-bin') + const secondExecutable = join(secondDirectory, 'printf') + mkdirSync(secondDirectory) + writeFileSync(secondExecutable, '#!/bin/sh\nexit 0\n') + chmodSync(secondExecutable, 0o755) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'printf' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, PATH: `${directory}:${secondDirectory}` } + }).trim() + + expectResolvedExecutable(resolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'makes relative and trailing-empty PATH matches absolute', + () => { + withExecutableFixture('relative-agent', (directory, executable, root) => { + const relativeDirectory = basename(directory) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'relative-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const relativeResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, PATH: `${relativeDirectory}:` } + }).trim() + const trailingResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: '/missing:' } + }).trim() + const emptyResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: ':/missing' } + }).trim() + + expectResolvedExecutable(relativeResolved, executable) + expectResolvedExecutable(trailingResolved, executable) + expectResolvedExecutable(emptyResolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'handles leading-dash names and explicit relative paths', + () => { + withExecutableFixture('-agent', (directory, executable) => { + const script = [ + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: './-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const output = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: directory, cmd: '-agent' } + }) + .trim() + .split('\n') + + expectResolvedExecutable(output[0], executable) + expectResolvedExecutable(output[1], executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'keeps set -e callers running when the command is absent', + () => { + const script = [ + 'set -e', + buildPosixCommandPathLookupScript({ + kind: 'literal', + value: '__orca_missing_command_path_lookup__' + }), + `printf '%s\\n' survived` + ].join('\n') + + expect(execFileSync('/bin/sh', ['-c', script], { encoding: 'utf8' }).trim()).toBe('survived') + } + ) + + it.each(['', '$cmd', 'cmd-name', 'cmd; echo injected'])( + 'rejects an unsafe shell variable name: %s', + (name) => { + expect(() => buildPosixCommandPathLookupScript({ kind: 'shell-variable', name })).toThrow( + 'Invalid shell variable name' + ) + } + ) + + it('quotes literal targets before assigning them in the generated shell fragment', () => { + const script = buildPosixCommandPathLookupScript({ + kind: 'literal', + value: "agent'; echo injected; '" + }) + + expect(script).toContain(`_orca_lookup_command='agent'\\''; echo injected; '\\'''`) + }) + + it.skipIf(!canRunWslSh())( + 'resolves through the Windows-to-WSL login-shell boundary with inline masks', + () => { + const lookup = buildPosixCommandPathLookupScript({ kind: 'literal', value: 'sh' }) + const command = buildWslLoginShellCommand( + [ + `sh() { printf '%s\\n' masked-function; }`, + `alias sh='printf masked-alias'`, + lookup, + `printf '%s' "$resolved"` + ].join('\n') + ) + const resolved = execFileSync( + 'wsl.exe', + ['--', 'sh', '-lc', escapeWslShCommandForWindows(command)], + { encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS } + ).trim() + + expect(resolved).toMatch(/^\/.+\/sh$/) + }, + 30_000 + ) +}) + +function expectResolvedExecutable(resolved: string, executable: string): void { + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(executable)) +} + +function canRunWslSh(): boolean { + if (!isWindows) { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + +function withExecutableFixture( + name: string, + run: (directory: string, executable: string, root: string) => void +): void { + const root = mkdtempSync(join(tmpdir(), 'orca-path-lookup-')) + const directory = join(root, 'bin') + const executable = join(directory, name) + try { + mkdirSync(directory) + writeFileSync(executable, '#!/bin/sh\nexit 0\n') + chmodSync(executable, 0o755) + run(directory, executable, root) + } finally { + rmSync(root, { force: true, recursive: true }) + } +} + +function executablePath(candidates: readonly string[]): string | null { + if (isWindows) { + return null + } + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Keep checking alternate standard locations when this entry is not executable. + } + } + return null +} diff --git a/src/shared/posix-command-path-lookup.ts b/src/shared/posix-command-path-lookup.ts new file mode 100644 index 00000000000..fb29be82294 --- /dev/null +++ b/src/shared/posix-command-path-lookup.ts @@ -0,0 +1,65 @@ +export type PosixCommandPathLookupTarget = + | { kind: 'literal'; value: string } + | { kind: 'shell-variable'; name: string } + +const SHELL_VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function buildPosixCommandPathLookupScript(target: PosixCommandPathLookupTarget): string { + const commandAssignment = buildCommandAssignment(target) + // Shell command resolution can be masked by aliases, functions, and builtins, so inspect PATH. + return [ + `_orca_lookup_command=${commandAssignment}`, + 'resolved=', + 'case "$_orca_lookup_command" in', + ' */*)', + ' case "$_orca_lookup_command" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' fi', + ' ;;', + ' *)', + ' _orca_lookup_remaining=${PATH-}', + ' while :; do', + ' case "$_orca_lookup_remaining" in', + ' *:*)', + ' _orca_lookup_component=${_orca_lookup_remaining%%:*}', + ' _orca_lookup_remaining=${_orca_lookup_remaining#*:}', + ' _orca_lookup_has_more=1', + ' ;;', + ' *)', + ' _orca_lookup_component=$_orca_lookup_remaining', + ' _orca_lookup_has_more=', + ' ;;', + ' esac', + ' [ -n "$_orca_lookup_component" ] || _orca_lookup_component=.', + ' case "$_orca_lookup_component" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_component/$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_component/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' break', + ' fi', + ' [ -n "$_orca_lookup_has_more" ] || break', + ' done', + ' ;;', + 'esac' + ].join('\n') +} + +function buildCommandAssignment(target: PosixCommandPathLookupTarget): string { + if (target.kind === 'literal') { + return shellQuote(target.value) + } + if (!SHELL_VARIABLE_NAME_PATTERN.test(target.name)) { + throw new Error(`Invalid shell variable name: ${target.name}`) + } + return `\${${target.name}-}` +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} From 4ead072a16b668c46615323021741190bfba1230 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:16:49 -0700 Subject: [PATCH 14/25] Fix Codex WSL runtime status hooks (#7969) * Fix Codex WSL runtime status hooks * Harden Codex WSL hook restart handling * Harden Codex WSL hook path handling * Fix Codex hook CLI type boundary --- config/tsconfig.cli.json | 1 + src/main/codex/codex-wsl-hook-install-plan.ts | 87 ++++++ .../codex/hook-service-wsl-runtime.test.ts | 295 ++++++++++++++++++ src/main/codex/hook-service.ts | 258 ++++++++++++++- src/main/index.ts | 14 +- 5 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 src/main/codex/codex-wsl-hook-install-plan.ts create mode 100644 src/main/codex/hook-service-wsl-runtime.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0fd87a36f7c..8d92fa208ca 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,7 @@ "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts new file mode 100644 index 00000000000..e7878777bff --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type CodexWslRuntimeHookTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type CodexWslRuntimeHookInstallPlan = { + configPath: string + tomlPath: string + scriptPath: string + commandScriptPath: string + trustConfigPath: string +} + +export type CanonicalizeWslLinuxPath = (distro: string, linuxPath: string) => string | null + +function trimTrailingSlash(value: string): string { + return value.length > 1 ? value.replace(/\/+$/, '') : value +} + +function toDefaultWslLinuxPath(windowsPath: string): string { + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` +} + +function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null { + if (process.platform !== 'win32') { + return linuxPath + } + try { + const canonicalPath = execFileSync( + 'wsl.exe', + ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + } + ).trim() + return canonicalPath.startsWith('/') ? canonicalPath : null + } catch { + return null + } +} + +export function createCodexWslRuntimeHookInstallPlan( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget, + canonicalize: CanonicalizeWslLinuxPath = canonicalizeWslLinuxPath +): CodexWslRuntimeHookInstallPlan | null { + if (!runtimeHomePath) { + return null + } + + const wslInfo = parseWslUncPath(runtimeHomePath) + if (!wslInfo && target?.runtime !== 'wsl') { + return null + } + const distro = wslInfo?.distro || (target?.runtime === 'wsl' ? target.wslDistro?.trim() : null) + if (!distro) { + return null + } + + const logicalLinuxRuntimeHome = wslInfo?.linuxPath ?? toDefaultWslLinuxPath(runtimeHomePath) + if (!logicalLinuxRuntimeHome.startsWith('/')) { + return null + } + // Why: Codex canonicalizes hook sources inside WSL; resolving there keeps + // trust keys valid when HOME or the runtime directory crosses a symlink. + const linuxRuntimeHome = trimTrailingSlash( + canonicalize(distro, logicalLinuxRuntimeHome) ?? logicalLinuxRuntimeHome + ) + + return { + configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), + scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxRuntimeHome}/hooks.json` + } +} diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts new file mode 100644 index 00000000000..2bbf2bdd20d --- /dev/null +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, win32 as pathWin32 } from 'node:path' + +import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + _internals, + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan +} from './hook-service' + +type HooksConfig = { + hooks: Record +} + +const managedEvents = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'Stop' +] as const + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function createTestPlan(): CodexWslRuntimeHookInstallPlan { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-wsl-hooks-')) + tempRoots.push(root) + const linuxHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + return { + configPath: join(root, 'hooks.json'), + tomlPath: join(root, 'config.toml'), + scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxHome}/hooks.json` + } +} + +function getManagedTrustEntry( + plan: CodexWslRuntimeHookInstallPlan, + command: string +): CodexTrustEntry { + return { + sourcePath: plan.trustConfigPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +describe('Codex WSL runtime hook install', () => { + it('plans WSL hook files with Linux command and trust paths', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan(runtimeHome, undefined, (_distro, path) => path) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('plans WSL hooks when the distro home is mounted on a Windows drive', () => { + const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (_distro, path) => path + ) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('uses WSL-canonical paths for hook commands and trust keys', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home' + const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + + const plan = createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (distro, linuxPath) => { + expect(distro).toBe('Ubuntu') + expect(linuxPath).toBe('/home/alias/.local/share/orca/codex-runtime-home/home') + return canonicalHome + } + ) + + expect(plan?.commandScriptPath).toBe(`${canonicalHome}/.orca/agent-hooks/codex-hook.sh`) + expect(plan?.trustConfigPath).toBe(`${canonicalHome}/hooks.json`) + expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json')) + }) + + it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => { + const script = _internals.getManagedScript('posix') + expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') + expect(script).toContain('post_codex_hook()') + expect(script).toContain('is_wsl_runtime()') + expect(script).toContain('WSL_DISTRO_NAME') + expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') + expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') + }) + + it.skipIf(process.platform === 'win32')( + 'refreshes stale hook coordinates from a Windows endpoint file', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const endpointPath = join(root, 'endpoint.cmd') + const binDir = join(root, 'bin') + const curlPath = join(binDir, 'curl') + const capturePath = join(root, 'curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync( + endpointPath, + [ + 'set ORCA_AGENT_HOOK_PORT=43210', + 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\r\n'), + 'utf-8' + ) + writeFileSync( + curlPath, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(curlPath, 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"UserPromptSubmit","prompt":"restart"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + ORCA_AGENT_HOOK_ENDPOINT: endpointPath, + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).not.toContain('stale-token') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the Windows curl discovered from the WSL PATH after loopback fails', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const binDir = join(root, 'bin') + const capturePath = join(root, 'windows-curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync(join(binDir, 'curl'), '#!/bin/sh\nexit 7\n', 'utf-8') + writeFileSync( + join(binDir, 'curl.exe'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(join(binDir, 'curl'), 0o755) + chmodSync(join(binDir, 'curl.exe'), 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_PORT: '43210', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: token') + } + ) + + it('installs trusted WSL hooks and removes only Orca entries when disabled', () => { + const plan = createTestPlan() + const userCommand = '/bin/sh /home/alice/user-hook.sh' + writeFileSync( + plan.configPath, + `${JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }], + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/old/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/old/.orca/agent-hooks/codex-hook.sh'; fi" + } + ] + } + ] + } + })}\n`, + 'utf-8' + ) + const unrelatedTrustKey = '/home/alice/custom/hooks.json:stop:0:0' + writeFileSync( + plan.tomlPath, + `[hooks.state."${unrelatedTrustKey}"]\nenabled = false\ntrusted_hash = "sha256:user"\n`, + 'utf-8' + ) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) + const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command + expect(managedCommand).toBe( + `if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi` + ) + expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) + expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe') + + const managedTrustEntry = getManagedTrustEntry(plan, managedCommand!) + const trustEntries = readHookTrustEntries(plan.tomlPath) + expect(trustEntries.get(computeTrustKey(managedTrustEntry))).toEqual({ + enabled: true, + trustedHash: computeTrustedHash(managedTrustEntry) + }) + expect(trustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + + expect(_internals.refreshWslRuntimeUserHooks(plan).state).toBe('not_installed') + + const refreshed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(refreshed.hooks).toEqual({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }] + }) + const refreshedTrustEntries = readHookTrustEntries(plan.tomlPath) + expect(refreshedTrustEntries.has(computeTrustKey(managedTrustEntry))).toBe(false) + expect(refreshedTrustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index ef812ab7ff0..31e552df55c 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -43,6 +43,11 @@ import { } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookTarget +} from './codex-wsl-hook-install-plan' import { CODEX_HOOK_EVENT_LABEL, createCodexHookTrustEntry, @@ -123,6 +128,16 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } +export { createCodexWslRuntimeHookInstallPlan } +export type { CodexWslRuntimeHookInstallPlan } + +function wrapReadablePosixHookCommand(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + // Why: WSL runtime hooks are written from Windows through UNC, where the + // executable bit is not reliable. /bin/sh only needs the script to be readable. + return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi` +} + function getSystemConfigPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -662,6 +677,52 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { } } +function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { + try { + const existingEntries = readHookTrustEntries(plan.tomlPath) + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) + const ourKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: plan.trustConfigPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ourKeys.push(key) + } + } + if (ourKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ourKeys) + } + } catch (error) { + // Why: removing disabled WSL status hooks should be best-effort like the + // host cleanup path; stale trust is inert once hooks.json no longer points at us. + console.warn('[codex-hook-service] failed to clean WSL trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -686,8 +747,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. + 'load_hook_endpoint() {', + ' endpoint_path="$1"', + ' case "$endpoint_path" in', + ' *.cmd)', + // Why: Windows passes endpoint.cmd into WSL through WSLENV path translation. + // Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax. + ' endpoint_cr=$(printf "\\r")', + ' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do', + ' endpoint_line=${endpoint_line%"$endpoint_cr"}', + ' case "$endpoint_line" in', + ' "set ORCA_AGENT_HOOK_PORT="*) ORCA_AGENT_HOOK_PORT=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' esac', + ' done < "$endpoint_path"', + ' ;;', + ' *)', + ' . "$endpoint_path" 2>/dev/null || :', + ' ;;', + ' esac', + '}', 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', @@ -696,6 +779,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'post_codex_hook() {', + ' curl_bin="$1"', + ' connect_timeout="${2:-0.5}"', + ' max_time="${3:-1.5}"', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -703,23 +790,164 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', + ' --noproxy "127.0.0.1" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-"', + '}', + 'is_wsl_runtime() {', + ' [ -n "$WSL_DISTRO_NAME" ] && return 0', + ' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null', + '}', + 'if post_codex_hook curl >/dev/null 2>&1; then', + ' exit 0', + 'fi', + 'if is_wsl_runtime; then', + ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', + ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', + ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' fi', + 'fi', 'exit 0', '' ].join('\n') } +function installManagedHooksIntoWslRuntime( + plan: CodexWslRuntimeHookInstallPlan +): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const trustEntries: CodexTrustEntry[] = [] + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [buildManagedCommandHook(command)] + } + nextHooks[eventName] = [definition, ...cleaned] + trustEntries.push({ + sourcePath: plan.trustConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + + config.hooks = nextHooks + writeManagedScript(plan.scriptPath, getManagedScript('posix')) + writeCodexHooksJson(plan.configPath, nextHooks) + try { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: plan.configPath, + managedHooksPresent: true, + detail: null + } +} + +function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const nextHooks = { ...config.hooks } + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + writeCodexHooksJson(plan.configPath, nextHooks) + removeWslRuntimeManagedHookTrustEntries(plan) + return { + agent: 'codex', + state: 'not_installed', + configPath: plan.configPath, + managedHooksPresent: false, + detail: null + } +} + export class CodexHookService { + installForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + } + + refreshRuntimeUserHooksForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -1155,3 +1383,9 @@ export class CodexHookService { } export const codexHookService = new CodexHookService() + +export const _internals = { + getManagedScript, + installManagedHooksIntoWslRuntime, + refreshWslRuntimeUserHooks +} diff --git a/src/main/index.ts b/src/main/index.ts index 8c2a5a4e160..0eb2d9d9a4f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,6 +122,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { @@ -694,13 +695,22 @@ async function startServeAgentHookServer(): Promise { function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) + const hookTarget = + target?.runtime === 'wsl' + ? { + runtime: 'wsl' as const, + wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() + } + : target const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor // the persisted off switch so those launches cannot reinstall removed hooks. const status = hooksEnabled - ? codexHookService.install() - : codexHookService.refreshRuntimeUserHooks() + ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.install()) + : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.refreshRuntimeUserHooks()) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${ From 4baaca601a9dd168d89e229f10061c0deb41c146 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:58:53 +0000 Subject: [PATCH 15/25] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index b712f768dce..1333939252a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.1m + + downloads: 4.2m @@ -15,7 +15,7 @@ downloads downloads - 4.1m - 4.1m + 4.2m + 4.2m From e8c84bb7041a761713fa9b532ea13f5064d78312 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:21:55 -0700 Subject: [PATCH 16/25] fix: propagate hook-only agent status to Remote Orca Server clients (#7970) (#7998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: propagate hook-only agent status to Remote Orca Server clients On a headless Remote Orca Server, agent-status hooks (OSC 9999) updated the retained row map but never republished PTY-backed session snapshots — only terminal *title* changes did. Paired desktop/web/mobile clients therefore kept a stale agent state (e.g. opencode working/idle) until relaunch, and even title-driven updates carried an empty prompt and no agent identity because the snapshot builder only used the title heuristic (#7970). - retainAgentRowSnapshot reports client-visible changes (state, prompt, agent type, tool, interactive prompt, interrupted) so handlePtyData can republish snapshots on hook-only transitions without fanning out a rebuild per repeated same-state hook ping. - buildPtyMobileAgentStatus prefers the fresh retained hook payload over the title-only fallback, so clients see the real state/prompt/agentType and interactive prompts. The non-agent-title suppression (#1437 stuck spinners) still wins unless the hook shows a live tool/question signal, and it now also covers leaf-backed panes with no PTY record. Co-authored-by: Orca * fix: refetch remote projects when the client-events stream replays worktreesChanged/reposChanged emitted during a transport gap are lost, not queued. A quick drop can replay without flipping the environment unreachable, so the reachability-transition refetch never runs and a server-created worktree stays invisible until relaunch (#7970). Request a debounced project refresh on the replay tag, mirroring the SSH-state refetch that already rides it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 115 ++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 141 +++++++++++++++++++++---- src/renderer/src/hooks/useIpcEvents.ts | 6 ++ 3 files changed, 240 insertions(+), 22 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7cf71bea957..eac3b6c6411 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11326,6 +11326,121 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) + // #7970: on a headless serve there is no renderer syncing tab.agentStatus, so + // hook-only transitions must republish the PTY-backed snapshot themselves and + // the snapshot must carry the retained hook payload, not just title evidence. + it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"opencode"}\x07', + 100 + ) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ + state: 'working', + prompt: 'fix the tests', + agentType: 'opencode' + }) + }) + ) + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"waiting","prompt":"fix the tests","agentType":"opencode"}\x07', + 101 + ) + + expect(events).toHaveLength(2) + expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual( + expect.objectContaining({ state: 'waiting' }) + ) + + unsubscribe() + }) + + it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-ping-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + const payload = '\x1b]9999;{"state":"working","prompt":"same","agentType":"codex"}\x07' + runtime.onPtyData('hook-ping-pty', payload, 100) + runtime.onPtyData('hook-ping-pty', payload, 101) + runtime.onPtyData('hook-ping-pty', payload, 102) + + expect(events).toHaveLength(1) + + unsubscribe() + }) + + it('suppresses a retained hook working status once the shell owns the pane title again', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-exit-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-exit-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-exit-pty', + '\x1b]9999;{"state":"working","prompt":"long task","agentType":"codex"}\x07', + 100 + ) + // Agent exits without a hook done event; the shell takes the title back. + // The stuck-spinner guard (#1437) must win over the retained hook row. + runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101) + + const last = events.at(-1)?.tabs[0] + expect(last?.type).toBe('terminal') + expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy() + + unsubscribe() + }) + it('stores normalized Pi idle OSC titles that still classify as idle', async () => { const runtime = new OrcaRuntimeService(store) syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9681ea33e14..546d40a5730 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5533,8 +5533,11 @@ export class OrcaRuntimeService { } } - this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) - if (shouldTouchPtyBackedSessionTabs) { + const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: hook (OSC 9999) transitions often arrive without a title change, so + // headless-serve snapshots would never republish and paired remote clients + // kept the stale agent state until the next title change (#7970). + if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5631,12 +5634,14 @@ export class OrcaRuntimeService { return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { + /** Returns true when any retained agent-row snapshot changed in a + * client-visible way, so the caller can republish session snapshots. */ + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing // onTerminalAgentStatus — only the per-target emit below is gated on it. if (chunk.payloads.length === 0) { - return + return false } const targets = new Map< string, @@ -5669,9 +5674,17 @@ export class OrcaRuntimeService { connectionId }) } + let retainedChanged = false for (const payload of chunk.payloads) { for (const target of targets.values()) { - this.retainAgentRowSnapshot(ptyId, target.paneKey, target.worktreeId, target.tabId, payload) + retainedChanged = + this.retainAgentRowSnapshot( + ptyId, + target.paneKey, + target.worktreeId, + target.tabId, + payload + ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -5692,6 +5705,7 @@ export class OrcaRuntimeService { } } } + return retainedChanged } private retainAgentRowSnapshot( @@ -5700,7 +5714,7 @@ export class OrcaRuntimeService { worktreeId: string | undefined, tabId: string | undefined, payload: ParsedAgentStatusPayload - ): void { + ): boolean { const now = Date.now() const previous = this.latestAgentStatusByPaneKey.get(paneKey) // Why: stateStartedAt must mark the transition into the current state, not @@ -5717,6 +5731,17 @@ export class OrcaRuntimeService { stateStartedAt, updatedAt: now }) + // Client-visible change detection: snapshot republish is gated on this so + // repeated same-state hook pings don't fan a rebuild out to every client. + return ( + !previous || + previous.payload.state !== payload.state || + previous.payload.prompt !== payload.prompt || + (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || + (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || + (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + ) } private clearAgentRowSnapshotsForPty(ptyId: string): void { @@ -19320,45 +19345,117 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null ): { agentStatus: AgentStatusEntry } | Record { - if (!pty?.lastAgentStatus) { + const paneKey = this.getMobileTerminalPaneKey(tab) + const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab) + if (!pty?.lastAgentStatus && !retained) { return {} } - const ptyTitle = getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null const ptyTitleClassification = classifyAgentTitle(ptyTitle) if (ptyTitle !== null && ptyTitleClassification !== 'agent') { - return {} + // Why: a non-agent title means the shell owns the pane again (the agent + // exited or was replaced) — suppressing here is what clears stuck + // spinners (#1437). A live hook signal (question card / active tool) is + // authoritative agent activity even under a task-named title, so it + // survives the suppression, mirroring the renderer-synced branch above. + const hasLiveHookSignal = + retained?.payload.interactivePrompt != null || retained?.payload.toolName != null + if (!hasLiveHookSignal) { + return {} + } } - const now = pty.lastOutputAt ?? Date.now() - const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null + const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null + const terminalTitle = normalizeCompatibleAgentTitleForOwner( + (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, + ownerAgent + ) + // Why: hook (OSC 9999) payloads carry the real state, prompt, and agent + // identity; the title heuristic below is a fallback with none of that. + // Without this, headless-serve clients only ever saw title-derived rows + // and hook-only transitions (e.g. opencode waiting) never surfaced (#7970). + if (retained) { + return { + agentStatus: normalizeCompatibleAgentStatusEntryForOwner( + { + ...retained.payload, + paneKey, + updatedAt: retained.updatedAt, + stateStartedAt: retained.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? retained.worktreeId) + ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle + }, + ownerAgent + ) + } + } + const now = pty!.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: - pty.lastAgentStatus === 'working' + pty!.lastAgentStatus === 'working' ? 'working' - : pty.lastAgentStatus === 'permission' + : pty!.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', updatedAt: now, stateStartedAt: now, - paneKey: this.getMobileTerminalPaneKey(tab), + paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), - worktreeId: pty.worktreeId, + worktreeId: pty!.worktreeId, tabId: tab.parentTabId, - terminalTitle: normalizeCompatibleAgentTitleForOwner( - getLatestPtyTitle(pty) ?? tab.title, - ownerAgent - ), + terminalTitle, stateHistory: [] } } } + /** The retained OSC 9999 hook row for this mobile tab, when fresh enough to + * trust. Looked up by pane identity first, then by PTY ownership because + * legacy `pane:N` leaf ids can drift from the hook-side pane key. */ + private getFreshRetainedAgentStatusForMobileTab( + paneKey: string, + pty: RuntimePtyWorktreeRecord | null, + tab: RuntimeMobileSessionTerminalTab + ): RuntimeAgentRowSnapshot | null { + let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null + if (!retained) { + const ptyId = pty?.ptyId ?? tab.ptyId ?? null + if (ptyId) { + for (const snapshot of this.latestAgentStatusByPaneKey.values()) { + if (snapshot.ptyId !== ptyId) { + continue + } + if (!retained || snapshot.updatedAt > retained.updatedAt) { + retained = snapshot + } + } + } + } + if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return null + } + return retained + } + private findPtyForMobileTerminalTab( worktreeId: string, tab: RuntimeMobileSessionTerminalTab, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 13202f13ac3..f918744e62c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1033,6 +1033,12 @@ export function useIpcEvents(): void { getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, subscribe: (environmentId, onEvent, onError) => subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => { + // Why: worktreesChanged/reposChanged during the transport gap are + // lost, not queued. A quick drop can replay without ever flipping the + // env unreachable, so the reachability-transition refetch never runs + // and a server-created worktree stays invisible until relaunch + // (#7970). The scheduler debounces, so this stays cheap. + runtimeProjectRefreshScheduler.request(environmentId) if (isPairedWebClientWindow()) { return } From 43f639dddaa6f2a1cc0c46d3394932f53d79bb96 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:23:36 -0700 Subject: [PATCH 17/25] Consolidate mobile source control into a single tabbed hub (#7923) * Consolidate mobile source control into a single tabbed hub Unify the changes list, pull request details, and commit history into a single multi-segment panel. This improves navigation and state sharing across different lenses of a worktree's source control. - Add a segmented control to switch between Changes, PR, and History - Introduce a persistent branch status card with an integrated PR chip - Redirect standalone PR and history routes to the new unified hub - Extract reusable UI and logic for the history list and PR summary * Keep mobile source control tabs mounted to preserve view state * Keep PR and History segments mounted (using display: 'none' when hidden) to preserve fetch, scroll, and expand states during tab switches. * Decouple the History list from blocking on Git status loading. * Support deep linking directly into the history tab of the main panel instead of using a standalone route. * Enable retrying failed loads by reviving the transport loop if parked. * Fix PR chip accessibility label and comment check. * Optimize and integrate mobile PR view within source control hub - Lazy-load heavy PR comments and descriptions (Phase 2) only when the PR tab is active, using fast metadata (Phase 1) for the branch chip. - Unmount the PR body when inactive to avoid unnecessary comment tree re-renders and preserve WebView resources during commit text editing. - Implement soft-refresh on HEAD advancement to keep the ready UI visible while re-fetching checks post-commit. - Display the "Aborting..." label only when a merge or rebase abort is actively in flight. - Memoize the git history list and skip branch identity RPCs when gating the dock icon. * Improve mobile git views and concurrent rendering safety - Pass the `origin` parameter through history and PR redirect routes. - Move source control panel ref updates to `useEffect` to prevent side effects during concurrent renders. - Resolve commit file changes to empty if disconnected to avoid a stuck loading spinner. - Standardize PR sidebar header button styling and accessibility labels. * Resolve PR repo probe without active branch to avoid forever spinner Previously, checking if a repository is a GitHub remote required an active branch. In a detached HEAD or mid-rebase state (where the branch is null), the probe never resolved, leaving the PR panel on a forever spinner. Decouple the repository probe from the branch presence so the panel can correctly display the "Current branch unavailable" state. Also, hide the PR status chip when no branch is active to avoid a spinner on the chip. --- .../app/h/[hostId]/history/[worktreeId].tsx | 254 +- mobile/app/h/[hostId]/pr/[worktreeId].tsx | 49 +- .../app/h/[hostId]/session/[worktreeId].tsx | 41 +- .../[hostId]/source-control/[worktreeId].tsx | 3 + mobile/mock-source-control-hub.html | 2188 +++++++++++++++++ .../pr-sidebar/MobilePrViewPanel.tsx | 167 +- .../components/pr-sidebar/PRSidebarHeader.tsx | 41 +- .../pr-sidebar/mobile-pr-sidebar-styles.ts | 7 + mobile/src/session/SessionDockColumn.tsx | 60 +- mobile/src/session/mobile-pr-sidebar-state.ts | 41 + mobile/src/session/session-panel-host.test.ts | 3 +- mobile/src/session/session-panel-host.ts | 9 +- .../session/use-mobile-pr-branch-context.ts | 19 +- ...mobile-pr-sidebar-controller-probe.test.ts | 100 + .../use-mobile-pr-sidebar-controller.test.ts | 62 + .../use-mobile-pr-sidebar-controller.ts | 247 +- .../source-control/MobileGitHistoryList.tsx | 271 ++ .../MobileSourceControlBranchCard.tsx | 78 + .../MobileSourceControlContent.tsx | 48 +- .../MobileSourceControlHeader.tsx | 25 +- .../MobileSourceControlPanel.tsx | 327 ++- .../MobileSourceControlPrChip.tsx | 121 + .../MobileSourceControlSegments.tsx | 45 + .../mobile-pr-chip-summary.test.ts | 138 ++ .../source-control/mobile-pr-chip-summary.ts | 104 + ...bile-source-control-conflict-abort.test.ts | 28 + .../mobile-source-control-conflict-abort.ts | 18 + .../mobile-source-control-hub-styles.ts | 126 + .../mobile-source-control-hub-tab.test.ts | 22 + .../mobile-source-control-hub-tab.ts | 29 + .../use-mobile-source-control-runners.ts | 29 +- .../use-mobile-source-control-state.ts | 9 +- 32 files changed, 4087 insertions(+), 622 deletions(-) create mode 100644 mobile/mock-source-control-hub.html create mode 100644 mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts create mode 100644 mobile/src/source-control/MobileGitHistoryList.tsx create mode 100644 mobile/src/source-control/MobileSourceControlBranchCard.tsx create mode 100644 mobile/src/source-control/MobileSourceControlPrChip.tsx create mode 100644 mobile/src/source-control/MobileSourceControlSegments.tsx create mode 100644 mobile/src/source-control/mobile-pr-chip-summary.test.ts create mode 100644 mobile/src/source-control/mobile-pr-chip-summary.ts create mode 100644 mobile/src/source-control/mobile-source-control-conflict-abort.test.ts create mode 100644 mobile/src/source-control/mobile-source-control-conflict-abort.ts create mode 100644 mobile/src/source-control/mobile-source-control-hub-styles.ts create mode 100644 mobile/src/source-control/mobile-source-control-hub-tab.test.ts create mode 100644 mobile/src/source-control/mobile-source-control-hub-tab.ts diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index ce7309eff0f..19198e4e5f1 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -1,242 +1,28 @@ -import { useCallback, useEffect, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native' -import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context' -import type { RpcSuccess } from '../../../../src/transport/types' -import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' -import { - fetchMobileGitHistory, - mapMobileCommitRows, - type MobileCommitRow -} from '../../../../src/source-control/mobile-git-history' -import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state' -import type { GitBranchChangeEntry } from '../../../../../src/shared/types' +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' -function firstParam(value: string | string[] | undefined): string { - return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') -} - -export default function HistoryScreen() { +// History is now a segment of the Source Control hub. This route stays as a thin +// redirect so existing deep links (and any cached navigation) land on the hub with +// the History segment selected. +export default function HistoryRedirect() { const params = useLocalSearchParams<{ hostId?: string | string[] worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] }>() - const hostId = firstParam(params.hostId) - const worktreeId = firstParam(params.worktreeId) - const router = useRouter() - const insets = useSafeAreaInsets() - const { client, state: connState } = useHostClient(hostId) - const forceReconnect = useForceReconnect() - - const [rows, setRows] = useState(null) - const [error, setError] = useState(null) - const [reloadNonce, setReloadNonce] = useState(0) - const [expanded, setExpanded] = useState(null) - const [filesById, setFilesById] = useState>({}) - - useEffect(() => { - let active = true - if (!client || connState !== 'connected' || !worktreeId) { - return - } - // Reset prior error/rows so a successful retry doesn't stay stuck behind a - // stale error (error wins render precedence). - setError(null) - setRows(null) - void (async () => { - try { - const result = await fetchMobileGitHistory(client, worktreeId) - if (active) { - setRows(mapMobileCommitRows(result, Date.now())) - } - } catch (err) { - if (active) { - setError(err instanceof Error ? err.message : 'Failed to load history') - } - } - })() - return () => { - active = false - } - }, [client, connState, reloadNonce, worktreeId]) - - const retry = useCallback(() => { - setError(null) - // Why: retrying the fetch is useless while the transport's reconnect loop - // is parked at its backoff cap — revive the connection instead (mirrors - // MobileSourceControlPanel / issue #5049). The load effect re-runs via - // connState once the fresh client connects. - if (connState !== 'connected' && hostId) { - void forceReconnect(hostId) - return - } - setReloadNonce((n) => n + 1) - }, [connState, forceReconnect, hostId]) - - const toggleCommit = useCallback( - (row: MobileCommitRow) => { - const next = expanded === row.id ? null : row.id - setExpanded(next) - if (next && client && !filesById[row.id]) { - setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) - void client - .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) - .then((response) => { - const entries = response.ok - ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries - : [] - setFilesById((prev) => ({ ...prev, [row.id]: entries })) - }) - .catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] }))) - } - }, - [client, expanded, filesById, worktreeId] - ) - - const renderCommit = useCallback( - ({ item }: { item: MobileCommitRow }) => { - const files = filesById[item.id] - const isOpen = expanded === item.id - return ( - - [styles.commitHeader, pressed && styles.commitHeaderPressed]} - onPress={() => toggleCommit(item)} - > - {isOpen ? ( - - ) : ( - - )} - - - {item.subject} - - - {item.shortId} · {item.author} · {item.relativeTime} - - - - {isOpen ? ( - - {files === 'loading' || files === undefined ? ( - - ) : files.length === 0 ? ( - No file changes - ) : ( - files.map((file) => ( - - - {file.path} - - - {file.added ? +{file.added} : null} - {file.removed ? -{file.removed} : null} - - - )) - )} - - ) : null} - - ) - }, - [expanded, filesById, toggleCommit] - ) - - const view = resolveMobileHistoryScreenView({ - connected: client !== null && connState === 'connected', - rows, - error - }) - return ( - - - router.back()} accessibilityLabel="Back"> - - - Commit History - - {view.kind === 'error' || view.kind === 'waiting' ? ( - - - {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} - - - Retry - - - ) : view.kind === 'loading' ? ( - - - - ) : view.kind === 'empty' ? ( - - No commits. - - ) : ( - row.id} - contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }} - /> - )} - + ) } - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bgBase }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.sm - }, - back: { padding: spacing.xs }, - title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, - stateText: { color: colors.textMuted, fontSize: typography.bodySize }, - retryButton: { - marginTop: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radii.button, - backgroundColor: colors.bgRaised - }, - retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, - commitHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2 - }, - commitHeaderPressed: { backgroundColor: colors.bgRaised }, - commitMain: { flex: 1, minWidth: 0 }, - commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, - commitMeta: { - color: colors.textMuted, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily, - marginTop: 2 - }, - files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, - fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - filePath: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, - add: { color: colors.gitDecorationAdded }, - del: { color: colors.gitDecorationDeleted }, - empty: { color: colors.textMuted, fontSize: typography.metaSize } -}) diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx index 2e0530b0f4b..682e4d3481c 100644 --- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx @@ -1,30 +1,29 @@ -import { useLocalSearchParams } from 'expo-router' -import { useHostClient } from '../../../../src/transport/client-context' -import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' -import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel' - -// Narrow-layout full-screen PR route. The standalone panel can't ride on the review -// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare. -export default function MobilePrViewScreen() { - const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>() - const { client, state: connState } = useHostClient(hostId) - const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +// The Pull Request view is now a segment of the Source Control hub. This route +// stays as a thin redirect so existing deep links land on the hub with the Pull +// Request segment selected. The wide-layout dock opens the same hub with +// initialTab="pr" (SessionDockColumn), not this route. +export default function PrRedirect() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] + }>() return ( - ) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b556170b4c7..da2aae83fce 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -856,20 +856,16 @@ export default function SessionScreen() { setActivePanel(null) } }, [canDockPanel, activePanel]) - // Session-level PR context feeds the docked PR panel and gates the GitHub-only - // PR entry so GitLab/other providers do not open a GitHub RPC surface. - const { - branch: prBranch, - headSha: prHeadSha, - status: prStatus, - isGithubRepo: prIsGithubRepo, - repoLoaded: prRepoContextLoaded, - loaded: prContextLoaded - } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) + // Session-level GitHub remote probe gates the PR dock icon so non-GitHub + // providers do not open the hosted-review surface. Branch/head/status for the + // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs. + const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = + useMobilePrBranchContext({ + client, + connState, + worktreeId, + includeBranchIdentity: false + }) useEffect(() => { if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') { setActivePanel(null) @@ -4431,14 +4427,18 @@ export default function SessionScreen() { setActivePanel(action.next) return } + const descriptor = panelRouteDescriptor(action.panel) router.push({ - pathname: panelRouteDescriptor(action.panel).pathname, + pathname: descriptor.pathname, params: { hostId, worktreeId, name: worktreeName || '', - // Source control's post-diff-open dismissal keys off origin: 'session' (U2). - ...(action.panel === 'sourceControl' ? { origin: 'session' } : {}) + // SC + PR both land on the source-control hub; post-diff-open dismissal + // keys off origin: 'session' (U2). Files keeps its own route without origin. + ...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}), + // The PR panel routes into the hub's Pull Request segment via descriptor params. + ...descriptor.params } }) } @@ -5124,13 +5124,6 @@ export default function SessionScreen() { hostId={hostId} worktreeId={worktreeId} name={worktreeName || ''} - client={client} - connState={connState} - branch={prBranch} - headSha={prHeadSha} - gitStatus={prStatus} - isGithubRepo={prIsGithubRepo} - branchContextLoaded={prContextLoaded && prRepoContextLoaded} availableWidth={sessionContentRowWidth} onRequestClose={() => setActivePanel(null)} onFileOpenStart={handleFileOpenStart} diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index b9346eecaa5..1de11662dd8 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router' import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab' export default function MobileSourceControlScreen() { const params = useLocalSearchParams<{ @@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() { worktreeId?: string | string[] name?: string | string[] origin?: string | string[] + tab?: string | string[] }>() return ( ) diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html new file mode 100644 index 00000000000..0a64d75ecbf --- /dev/null +++ b/mobile/mock-source-control-hub.html @@ -0,0 +1,2188 @@ + + + + + + Orca Mobile – Source Control Hub + + + +
+ +
+ 9:41 + + + + + + + + + + + + + +
+ + +
+ +
+

Source Control

+
make-center-split-more-visible
+
+ +
+ + +
+ + + +
+ + +
+
+ + + + + + + make-center-split-more-visible +
+
+ + ↑2 + ↓0 + + 3 changed · 1 staged +
+
+
+ + + + + + #7701 + Open + + ✓ 12/12 + 💬 3 + + + + + + +
+
+ + +
+ +
+
+ +
+ Staged Changes + 1 + + +
+
+
+ ↩ + Unstage +
+
+ 🗑 + Discard +
+
+ + + + + + M + + + + + + + + EditorFileTab.tsx + + ‎src/renderer/src/components/tab-bar/ + +12−4 + + + + + + + +
+
+ + +
+ Changes + 3 + + +
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + CenterSplitDivider.tsx + + ‎src/renderer/src/components/split-view/ + +34−8 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + split-view-geometry.ts + + ‎src/renderer/src/lib/ + +6−2 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + A + + + + + SplitDividerHandle.test.tsx + + ‎src/renderer/src/components/split-view/__tests__/ + +48−0 + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+ 0 selected + + + +
+
+ + +
+
+
+ +
+
+ Open + #7701 + octocat wants to merge into main +
+
Make center split divider more visible
+
+ make-center-split-more-visible → main · updated 20m ago +
+
+ + +
+ 1 check failing + + +
+
+ + + + + e2e / playwright (ubuntu-latest) + Failing · 1 test · 2m 14s + + +
+ + +
+
+ Merge method + Squash and merge + + + + +
+
+
+
Auto-merge when checks pass
+
Merges automatically once required checks are green
+
+
+ +
+
+ +
1 failing check must pass before merging
+
+ + +
+ Unresolved comments + · 3 + + +
+
+ R + + rauchg · 18m ago + Can we clamp the divider width so it doesn't overlap the gutter on narrow + panes? + CenterSplitDivider.tsx:42 + +
+
+ M + + maren · 25m ago + Add a test for the min-width geometry branch. + split-view-geometry.ts:16 + +
+ + +
+ Reviewers +
+
+ O + octocat + Approved +
+
+ D + dstndev + Review requested +
+ + +
+ All checks + · 11 passed · 1 failing + + +
+
+ + + build / typecheckPassed · 1m 02s +
+
+ + + lint / oxlintPassed · 38s +
+
+
+
+ + +
+
+
+
+
+
+
Make center split divider more visible
+
+ a1b2c3d · Jinjing · 18m ago +
+
+
+
+
+
+
Add split-view geometry helper
+
+ 9f8e7d6 · Jinjing · 42m ago +
+
+
+ +
+ 2 commits ahead of origin/main + +
+ +
+
+
+
Merge pull request #7699 from orca/main
+
+ 0f7658d · web-flow · 3h ago +
+
+
+
+
+
+
Update README downloads badge
+
2dad1f0 · Jinjing · 5h ago
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + + + diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index 2b9a9c78bca..5eb17e243d9 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -1,15 +1,11 @@ -import { useEffect } from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ExternalLink, X } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { StyleSheet, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { colors } from '../../theme/mobile-theme' import type { ConnectionState } from '../../transport/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobileGitStatusResult } from '../../source-control/mobile-git-status' -import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' +import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' import { MobilePRSidebar } from '../MobilePRSidebar' -import { openMobilePrUrl } from '../MobilePrComposeSheet' type Props = { client: RpcClient | null @@ -20,13 +16,13 @@ type Props = { gitStatus: MobileGitStatusResult | null isGithubRepo?: boolean branchContextLoaded?: boolean - // Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close - // affordance; the dock column owns the safe-area insets. Full-screen otherwise. - embedded?: boolean - onRequestClose?: () => void + controller: MobilePrSidebarController } -export function MobilePrViewPanel({ +// Chromeless PR sidebar body for the source-control hub's Pull Request segment. +// The hub owns the header, segmented control, load triggers, and the shared +// controller (one fetch feeds both the branch-card chip and this body). +export function MobilePrViewPanelBody({ client, connState, worktreeId, @@ -35,32 +31,10 @@ export function MobilePrViewPanel({ gitStatus, isGithubRepo = true, branchContextLoaded = true, - embedded = false, - onRequestClose + controller }: Props) { - const router = useRouter() const insets = useSafeAreaInsets() - const controller = useMobilePrSidebarController({ - client, - connState, - worktreeId, - branch, - headSha - }) - // A docked/full-screen PR panel is always visible — there is no drawer to open, - // so trigger the load directly once context is ready rather than gating on the - // showPRSidebar overlay flag (KTD4). - const prSidebarKind = controller.prSidebarState.kind - const refetch = controller.refetchPRSidebar - useEffect(() => { - if (branch && isGithubRepo && prSidebarKind === 'hidden') { - refetch() - } - }, [branch, isGithubRepo, prSidebarKind, refetch]) - - // Embedded: the dock column applies the bottom inset; full-screen relies on its own - // SafeAreaView (edges top only), so content must clear the home indicator itself. const sidebarState = !branchContextLoaded ? ({ kind: 'loading' } as const) : !isGithubRepo @@ -74,86 +48,22 @@ export function MobilePrViewPanel({ message: 'Current branch unavailable.' } as const) : controller.prSidebarState - // Why: open-on-host lives in the chrome so the PR URL is always flush-right of - // the screen title, even when the body is scrolled past the PR header. - const prUrl = - sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null - const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null - const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined - const openPrControl = openPr ? ( - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={openPr} - hitSlop={8} - accessibilityRole="link" - accessibilityLabel={ - prNumber != null - ? `Open pull request #${prNumber} on the web` - : 'Open pull request on the web' - } - > - - - ) : null - const sidebar = ( - - ) - - if (embedded) { - return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={onRequestClose} - hitSlop={8} - accessibilityLabel="Close pull request panel" - > - - - - Pull Request - - {openPrControl} - - - {sidebar} - - ) - } return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={() => router.back()} - hitSlop={8} - accessibilityLabel="Back to session" - > - - - - Pull Request - - {openPrControl} - - - {sidebar} - + + + ) } @@ -161,34 +71,5 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase - }, - header: { - backgroundColor: colors.bgPanel, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - topBar: { - minHeight: 58, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingHorizontal: spacing.md - }, - iconButton: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - iconButtonPressed: { - backgroundColor: colors.bgRaised - }, - title: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.titleSize, - fontWeight: '600' } }) diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index d974089c326..bf083303132 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' -import { ArrowRight, Pencil } from 'lucide-react-native' +import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types' import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action' @@ -36,19 +36,32 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) { return ( - [ - styles.badge, - { borderColor: badgeColor }, - pressed && { opacity: 0.6 } - ]} - > - {badge.label} - + + [ + styles.badge, + { borderColor: badgeColor }, + pressed && { opacity: 0.6 } + ]} + > + {badge.label} + + {openPr ? ( + [styles.iconButton, pressed && { opacity: 0.6 }]} + > + + + ) : null} + hostId: string worktreeId: string name: string - client: RpcClient | null - connState: ConnectionState - branch: string | null - headSha: string | null - gitStatus: MobileGitStatusResult | null - isGithubRepo: boolean - branchContextLoaded: boolean availableWidth: number onRequestClose: () => void onFileOpenStart?: () => void @@ -40,13 +29,6 @@ export function SessionDockColumn({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, availableWidth, onRequestClose, onFileOpenStart, @@ -63,13 +45,6 @@ export function SessionDockColumn({ hostId={hostId} worktreeId={worktreeId} name={name} - client={client} - connState={connState} - branch={branch} - headSha={headSha} - gitStatus={gitStatus} - isGithubRepo={isGithubRepo} - branchContextLoaded={branchContextLoaded} onRequestClose={onRequestClose} onFileOpenStart={onFileOpenStart} onOpenedFileDiff={onOpenedFileDiff} @@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, onRequestClose, onFileOpenStart, onOpenedFileDiff }: DockPanelContentProps) { - if (activePanel === 'sourceControl') { + // Source Control and Pull Request share one hub instance so swapping dock icons + // keeps commit draft / visited-tab / scroll state and lands on the right segment + // (design: PR dock maps to hub + tab=pr). + if (activePanel === 'sourceControl' || activePanel === 'pr') { return ( ) } - if (activePanel === 'files') { - return ( - - ) - } return ( - diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts index 5f5b679b210..4ce32a7cec7 100644 --- a/mobile/src/session/mobile-pr-sidebar-state.ts +++ b/mobile/src/session/mobile-pr-sidebar-state.ts @@ -140,6 +140,47 @@ export async function loadPrSidebarDetails( } } +// UI treats details===null as "still loading". After phase 2 finishes (success or +// non-fatal failure), never leave null — use prior comments if any, else empty body. +export function resolvePrSidebarDetailsAfterPhase2(args: { + fetched: GitHubWorkItemDetails | null + prior: GitHubWorkItemDetails | null + pr: PRInfo +}): GitHubWorkItemDetails { + if (args.fetched != null) { + return args.fetched + } + if (args.prior != null) { + return args.prior + } + return emptyPrSidebarDetails(args.pr) +} + +// Placeholder details so Description/Comments leave the spinner after a failed phase 2. +export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { + return { + item: { + id: `pr-${pr.number}`, + type: 'pr', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + labels: [], + updatedAt: pr.updatedAt, + author: null + }, + body: '', + comments: [] + } +} + +// Soft head refresh may restart an in-flight phase-1 load; only skip when there is +// no visible/in-flight sidebar work (hidden or terminal error states). +export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean { + return kind === 'ready' || kind === 'none' || kind === 'loading' +} + // Stale-response guard (KTD6): a load tagged with an older sequence must not // overwrite a newer one. The hook bumps a monotonic counter per load. export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean { diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index 1028e489821..fb2845143d1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => { pathname: '/h/[hostId]/files/[worktreeId]' }) expect(panelRouteDescriptor('pr')).toEqual({ - pathname: '/h/[hostId]/pr/[worktreeId]' + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { tab: 'pr' } }) }) }) diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index 280c368c543..fdf08602752 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -47,13 +47,18 @@ export function resolvePanelAction(args: { // Single source of truth for each panel's expo-router pathname pattern so narrow-push // and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params. -export function panelRouteDescriptor(panel: Exclude): { pathname: string } { +// The Pull Request panel is a segment of the source-control hub, so its narrow-push +// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route. +export function panelRouteDescriptor(panel: Exclude): { + pathname: string + params?: Record +} { switch (panel) { case 'sourceControl': return { pathname: '/h/[hostId]/source-control/[worktreeId]' } case 'files': return { pathname: '/h/[hostId]/files/[worktreeId]' } case 'pr': - return { pathname: '/h/[hostId]/pr/[worktreeId]' } + return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } } } } diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts index ef2cf1a48d5..d0816b02f71 100644 --- a/mobile/src/session/use-mobile-pr-branch-context.ts +++ b/mobile/src/session/use-mobile-pr-branch-context.ts @@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext( // Loads repo eligibility independently from branch/SHA. The header PR icon only // needs the cheap GitHub probe; the panel can keep loading branch context after // the entry point is already stable in the top bar. +// +// `includeBranchIdentity: false` skips git.status + branchCompare — use when the +// caller only gates a PR entry on hosted-repo eligibility (session dock icon). export function useMobilePrBranchContext(input: { client: RpcClient | null connState: ConnectionState worktreeId: string + includeBranchIdentity?: boolean }): MobilePrBranchContext { - const { client, connState, worktreeId } = input + const { client, connState, worktreeId, includeBranchIdentity = true } = input const [context, setContext] = useState({ branch: null, headSha: null, @@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + loaded: !includeBranchIdentity }) return } @@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + // Repo-only mode is "loaded" for branch fields immediately (they stay null). + loaded: !includeBranchIdentity }) void loadMobilePrRepoContext(client, worktreeId) @@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: { } }) + if (!includeBranchIdentity) { + return () => { + cancelled = true + } + } + void loadMobilePrBranchIdentity(client, worktreeId) .then((next) => { if (!cancelled) { @@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: { return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [ready, client, worktreeId, includeBranchIdentity]) return context } diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts new file mode 100644 index 00000000000..0645b6659c4 --- /dev/null +++ b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts @@ -0,0 +1,100 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' + +const fetchGithubRepoSlugMock = vi.fn() + +// Only the repo probe matters here; the load path never runs without a branch. +vi.mock('./github-pr-rpc', () => ({ + fetchGithubRepoSlug: (...args: unknown[]) => fetchGithubRepoSlugMock(...args), + fetchHostedReviewForBranch: vi.fn(), + fetchPRChecks: vi.fn(), + fetchPRForBranch: vi.fn(), + fetchWorkItemDetails: vi.fn() +})) +vi.mock('../source-control/mobile-pr-link', () => ({ + fetchWorktreeLinkedPR: vi.fn(async () => null) +})) + +import { + useMobilePrSidebarController, + type MobilePrSidebarController +} from './use-mobile-pr-sidebar-controller' + +let captured: MobilePrSidebarController | null = null + +function Harness(props: Parameters[0]) { + captured = useMobilePrSidebarController(props) + return null +} + +async function flush(): Promise { + // Two ticks: the probe's resolved promise (.then) plus its setState commit. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('useMobilePrSidebarController repo probe', () => { + const client = { sendRequest: vi.fn() } as unknown as RpcClient + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + captured = null + fetchGithubRepoSlugMock.mockReset() + }) + + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + }) + + // Regression: repo eligibility must not be gated on a branch. A detached HEAD / + // mid-rebase worktree (branch === null) still has to resolve the probe, or the + // Pull Request segment gets stranded on a forever spinner instead of the + // "Current branch unavailable" state. + it('resolves the probe when connected without a branch (detached HEAD)', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connected', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).toHaveBeenCalledWith(client, 'w') + expect(captured?.prSidebarRepoProbeLoaded).toBe(true) + expect(captured?.prSidebarIsGithubRepo).toBe(true) + // No branch means no PR load ran — state stays hidden, never a spinner. + expect(captured?.prSidebarState.kind).toBe('hidden') + }) + + it('does not probe until the client is connected', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connecting', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).not.toHaveBeenCalled() + expect(captured?.prSidebarRepoProbeLoaded).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts index 005c4dc0e5b..de77433fcb6 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts @@ -4,11 +4,15 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import type { GitHubPrReadOutcome } from './github-pr-rpc' import { classifyPrSidebarFailure, + emptyPrSidebarDetails, loadPrSidebarData, loadPrSidebarDetails, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps } from './mobile-pr-sidebar-state' +import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller' function ok(result: T): GitHubPrReadOutcome { return { ok: true, result } @@ -200,3 +204,61 @@ describe('shouldApplyResult', () => { expect(shouldApplyResult(2, 3)).toBe(false) }) }) + +describe('shouldSoftRefreshPrSidebarOnHeadChange', () => { + it('restarts ready/none/loading so mid-flight head advances are not stuck', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true) + }) + + it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false) + }) +}) + +describe('resolvePrSidebarDetailsAfterPhase2', () => { + it('prefers a successful fetch over prior details', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS) + }) + + it('keeps prior details when the fetch is non-fatal null', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior) + }) + + it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => { + const empty = resolvePrSidebarDetailsAfterPhase2({ + fetched: null, + prior: null, + pr: PR + }) + expect(empty.body).toBe('') + expect(empty.comments).toEqual([]) + expect(empty.item.number).toBe(7) + expect(empty.item.type).toBe('pr') + }) +}) + +describe('buildMobilePrSidebarIdentity', () => { + it('keys identity on worktree + branch, not head SHA', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat') + // Head advances on commit must not form a new identity — soft refresh keeps ready UI. + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' }) + ) + }) + + it('is null without a branch and changes when branch or worktree changes', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull() + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' }) + ) + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index b14f0dec783..415a7581b7d 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -11,7 +11,9 @@ import { import { loadPrSidebarData, loadPrSidebarDetails, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps, type PrSidebarState } from './mobile-pr-sidebar-state' @@ -27,12 +29,20 @@ type PrSidebarControllerInput = { headSha: string | null } -function buildPrSidebarIdentity(args: { +// Load options for the shared PR controller. The Source Control hub chip only needs +// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the +// Pull Request segment is actually open. +export type PrSidebarLoadOptions = { + includeDetails?: boolean +} + +// Identity is worktree + branch only. Head SHA advances on every commit and must not +// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead. +export function buildMobilePrSidebarIdentity(args: { worktreeId: string branch: string | null - headSha: string | null }): string | null { - return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null + return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null } export function useMobilePrSidebarController(input: PrSidebarControllerInput) { @@ -41,13 +51,31 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { // independent of whether the branch has an open PR (a no-PR branch shows an // empty state rather than hiding the icon). const [isGithubRepo, setIsGithubRepo] = useState(false) + // False until the probe resolves for this worktree. Consumers gate "unavailable + // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe. + const [repoProbeLoaded, setRepoProbeLoaded] = useState(false) const [state, setState] = useState({ kind: 'hidden' }) const [showPRSidebar, setShowPRSidebar] = useState(false) const loadSeqRef = useRef(0) + // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent + // phase-1 soft refresh (and vice versa) when chip bootstrap left details null. + const detailsSeqRef = useRef(0) + // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's + // fill-in effect fires as soon as phase 1 renders ready with null details — + // exactly when load()'s own phase 2 just started. Without this claim, every + // cold PR-segment open fetched the heavy details payload twice. + const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null) const stateIdentityRef = useRef(null) + const stateRef = useRef(state) + stateRef.current = state + const headShaRef = useRef(headSha) - const ready = client !== null && connState === 'connected' && !!branch - const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha }) + // Repo eligibility (a GitHub remote) is independent of the branch, so the probe + // must not require one: a detached HEAD / mid-rebase worktree (branch === null) + // would otherwise never set repoProbeLoaded, stranding the PR segment on a + // forever spinner instead of the "Current branch unavailable" state. + const probeReady = client !== null && connState === 'connected' + const identity = buildMobilePrSidebarIdentity({ worktreeId, branch }) const buildDeps = useCallback((): PrSidebarLoadDeps | null => { if (!client) { @@ -63,66 +91,209 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { }, [client]) // Probe whether this is a GitHub repo to decide icon availability (GitHub-only). + // Worktree change must reset eligibility; a brief disconnect must not — otherwise + // the hub shows "unavailable for this provider" and hides the chip mid-session. + useEffect(() => { + setIsGithubRepo(false) + setRepoProbeLoaded(false) + }, [worktreeId]) + useEffect(() => { let cancelled = false - if (!ready || !client) { - setIsGithubRepo(false) + if (!probeReady || !client) { return } void fetchGithubRepoSlug(client, worktreeId).then((outcome) => { if (!cancelled) { setIsGithubRepo(outcome.ok && outcome.result !== null) + setRepoProbeLoaded(true) } }) return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [probeReady, client, worktreeId]) useEffect(() => { if (!identity) { loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) return } if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) { - // Why: ready/loading data is scoped to branch+head. A branch switch must - // not let the open panel keep rendering the previous PR as "fresh." + // Why: ready/loading data is scoped to branch. A branch switch must not let + // the open panel keep rendering the previous PR as "fresh." loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) } }, [identity]) - const load = useCallback(async () => { + const load = useCallback( + async (options?: PrSidebarLoadOptions) => { + const includeDetails = options?.includeDetails ?? true + const deps = buildDeps() + const loadIdentity = identity + if (!deps || !branch || !loadIdentity) { + return + } + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq + // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2 + // ownership when its own phase 2 actually starts (the bump below), so a load + // superseded at the phase-1 guard can never orphan a detailsSeq and silently + // discard the only details fetch. A stale ensure applying mid-phase-1 is safe — + // its identity/number/kind guards only let matching details through. + const previousIdentity = stateIdentityRef.current + stateIdentityRef.current = loadIdentity + // Soft refresh: same branch already showing ready/none stays visible while + // checks re-fetch (head advanced after commit). Hard loading only on first load + // or after a real identity wipe. + const keepVisible = + previousIdentity === loadIdentity && + (stateRef.current.kind === 'ready' || + stateRef.current.kind === 'none' || + stateRef.current.kind === 'loading') + if (!keepVisible) { + setState({ kind: 'loading' }) + } + // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with + // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. + const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) + if ( + !shouldApplyResult(seq, loadSeqRef.current) || + stateIdentityRef.current !== loadIdentity + ) { + return + } + stateIdentityRef.current = loadIdentity + + // Keep prior comments/body visible across phase 1 when the same PR is still open. + // loadPrSidebarData always returns details:null; without this, soft refresh and + // PR-tab refresh blank the comment tree until phase 2 finishes. + const priorDetails = + next.kind === 'ready' && + stateRef.current.kind === 'ready' && + stateRef.current.data.details != null && + stateRef.current.data.pr.number === next.data.pr.number + ? stateRef.current.data.details + : null + + if (next.kind === 'ready' && priorDetails != null) { + setState({ kind: 'ready', data: { ...next.data, details: priorDetails } }) + if (!includeDetails) { + return + } + } else { + setState(next) + if (next.kind !== 'ready' || !includeDetails) { + return + } + } + + // Phase 2: refresh (or first-load) the heavy comments/body payload. + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) + // Release the claim unless a newer phase-2 superseded it (never clear theirs). + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone — + // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without + // bumping detailsSeq, and must not discard the in-flight details it preserved + // (ensure dedupes against this claim, so nothing would re-fetch them). + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== next.data.pr.number + ) { + return + } + // Non-fatal null must not leave details===null (UI treats that as forever-loading). + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ kind: 'ready', data: { ...stateRef.current.data, details } }) + }, + [buildDeps, branch, headSha, identity, worktreeId] + ) + + // Phase-2 only — used when the hub opens the PR segment after a chip-only load. + // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1. + const ensurePrSidebarDetails = useCallback(async () => { + const current = stateRef.current + if (current.kind !== 'ready' || current.data.details != null) { + return + } const deps = buildDeps() const loadIdentity = identity - if (!deps || !branch || !loadIdentity) { + if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) { return } - const seq = loadSeqRef.current + 1 - loadSeqRef.current = seq - stateIdentityRef.current = loadIdentity - setState({ kind: 'loading' }) - // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with - // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. - const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) - if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) { + const prNumber = current.data.pr.number + // A live phase-2 fetch for this PR is already in flight (its claim still owns + // the latest details seq) — do not start a duplicate. + const inFlight = detailsInFlightRef.current + if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) { return } - stateIdentityRef.current = loadIdentity - setState(next) - if (next.kind !== 'ready') { + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber) + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== prNumber + ) { return } - // Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never - // blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered. - const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) - if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) { - setState({ kind: 'ready', data: { ...next.data, details } }) + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ + kind: 'ready', + data: { ...stateRef.current.data, details } + }) + }, [buildDeps, identity, worktreeId]) + + // Soft-refresh checks when HEAD advances on the same branch (post-commit). + // Also restarts an in-flight phase-1 load so a mid-flight head advance is not + // applied with a stale SHA (headShaRef would otherwise advance with no reload). + useEffect(() => { + if (headShaRef.current === headSha) { + return } - }, [buildDeps, branch, headSha, identity, worktreeId]) + headShaRef.current = headSha + // Identity was just wiped (branch/worktree switch): stateRef still holds the + // pre-wipe state in this effect flush, which would start a load flavored for + // the OLD surface (e.g. heavy details on the Changes tab). Let the owning + // surface's hidden-state effects drive the first load for the new identity. + if (stateIdentityRef.current === null) { + return + } + const current = stateRef.current + if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) { + return + } + void load({ + includeDetails: current.kind === 'ready' && current.data.details != null + }) + }, [headSha, load]) const openPRSidebar = useCallback(() => { setShowPRSidebar(true) @@ -131,17 +302,27 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { stateIdentityRef.current !== identity || (state.kind !== 'ready' && state.kind !== 'loading') ) { - void load() + void load({ includeDetails: true }) + } else if (state.kind === 'ready' && state.data.details == null) { + void ensurePrSidebarDetails() } - }, [identity, state.kind, load]) + }, [identity, state, load, ensurePrSidebarDetails]) + + const retry = useCallback(() => { + void load({ includeDetails: true }) + }, [load]) return { prSidebarState: state, prSidebarIsGithubRepo: isGithubRepo, + prSidebarRepoProbeLoaded: repoProbeLoaded, showPRSidebar, setShowPRSidebar, openPRSidebar, - retryPRSidebar: load, - refetchPRSidebar: load + retryPRSidebar: retry, + refetchPRSidebar: load, + ensurePrSidebarDetails } } + +export type MobilePrSidebarController = ReturnType diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx new file mode 100644 index 00000000000..d88fae540d8 --- /dev/null +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -0,0 +1,271 @@ +import { memo, useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { useForceReconnect } from '../transport/client-context' +import { + fetchMobileGitHistory, + mapMobileCommitRows, + type MobileCommitRow +} from './mobile-git-history' +import { resolveMobileHistoryScreenView } from './mobile-history-screen-state' +import type { GitBranchChangeEntry } from '../../../src/shared/types' + +type Props = { + client: RpcClient | null + connState: ConnectionState + worktreeId: string + // Needed so Retry can revive a parked reconnect loop (STA-1511 / #5049). + hostId: string + bottomInset: number + // Bumped by the hub header refresh so History reloads without remounting. + refreshNonce?: number +} + +// Headerless commit-history list. Extracted from the /history route so the hub's +// History segment and the standalone route render the same body over one code path. +// Memoized: it stays mounted (hidden) while the Changes segment is active, and must +// not re-reconcile its FlatList on every commit-message keystroke re-render. +export const MobileGitHistoryList = memo(function MobileGitHistoryList({ + client, + connState, + worktreeId, + hostId, + bottomInset, + refreshNonce = 0 +}: Props) { + const forceReconnect = useForceReconnect() + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + const [reloadNonce, setReloadNonce] = useState(0) + const [expanded, setExpanded] = useState(null) + const [filesById, setFilesById] = useState>({}) + + // Worktree identity change must wipe history immediately — even while + // disconnected — so a kept-mounted hub segment never shows another tree's commits. + useEffect(() => { + setRows(null) + setError(null) + setExpanded(null) + setFilesById({}) + }, [worktreeId]) + + useEffect(() => { + let active = true + if (!client || connState !== 'connected' || !worktreeId) { + // Why: leave already-loaded rows (and expand state) alone across a drop — + // resolveMobileHistoryScreenView keeps them visible (STA-1511). + return + } + // Reset prior error/rows so a successful retry doesn't stay stuck behind a + // stale error (error wins render precedence). + setError(null) + setRows(null) + setExpanded(null) + setFilesById({}) + void (async () => { + try { + const result = await fetchMobileGitHistory(client, worktreeId) + if (active) { + setRows(mapMobileCommitRows(result, Date.now())) + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : 'Failed to load history') + } + } + })() + return () => { + active = false + } + }, [client, connState, reloadNonce, refreshNonce, worktreeId]) + + const retry = useCallback(() => { + setError(null) + // Why: retrying the fetch is useless while the transport's reconnect loop + // is parked at its backoff cap — revive the connection instead (mirrors + // MobileSourceControlPanel / issue #5049). The load effect re-runs via + // connState once the fresh client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + setReloadNonce((n) => n + 1) + }, [connState, forceReconnect, hostId]) + + const toggleCommit = useCallback( + (row: MobileCommitRow) => { + const next = expanded === row.id ? null : row.id + setExpanded(next) + if (next && !filesById[row.id]) { + // No client (disconnected while cached rows stay visible): resolve to an + // empty file list so the row shows "No file changes" instead of a spinner + // that never completes — no request can be made. + if (!client) { + setFilesById((prev) => ({ ...prev, [row.id]: [] })) + return + } + setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) + void client + .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) + .then((response) => { + const entries = response.ok + ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries + : [] + setFilesById((prev) => { + // Drop stale responses if the row is no longer loading (collapsed + re-opened). + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: entries } + }) + }) + .catch(() => + setFilesById((prev) => { + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: [] } + }) + ) + } + }, + [client, expanded, filesById, worktreeId] + ) + + const renderCommit = useCallback( + ({ item }: { item: MobileCommitRow }) => { + const files = filesById[item.id] + const isOpen = expanded === item.id + return ( + + [styles.commitHeader, pressed && styles.commitHeaderPressed]} + onPress={() => toggleCommit(item)} + > + {isOpen ? ( + + ) : ( + + )} + + + {item.subject} + + + {item.shortId} · {item.author} · {item.relativeTime} + + + + {isOpen ? ( + + {files === 'loading' || files === undefined ? ( + + ) : files.length === 0 ? ( + No file changes + ) : ( + files.map((file) => ( + + + {file.path} + + + {file.added ? +{file.added} : null} + {file.removed ? -{file.removed} : null} + + + )) + )} + + ) : null} + + ) + }, + [expanded, filesById, toggleCommit] + ) + + const view = resolveMobileHistoryScreenView({ + connected: client !== null && connState === 'connected', + rows, + error + }) + + if (view.kind === 'error' || view.kind === 'waiting') { + return ( + + + {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} + + + Retry + + + ) + } + if (view.kind === 'loading') { + return ( + + + + ) + } + if (view.kind === 'empty') { + return ( + + No commits. + + ) + } + return ( + row.id} + contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }} + /> + ) +}) + +const styles = StyleSheet.create({ + state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, + stateText: { color: colors.textMuted, fontSize: typography.bodySize }, + retryButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, + commitHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + commitHeaderPressed: { backgroundColor: colors.bgRaised }, + commitMain: { flex: 1, minWidth: 0 }, + commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, + commitMeta: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily, + marginTop: 2 + }, + files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, + fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + filePath: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, + add: { color: colors.gitDecorationAdded }, + del: { color: colors.gitDecorationDeleted }, + empty: { color: colors.textMuted, fontSize: typography.metaSize } +}) diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx new file mode 100644 index 00000000000..04aae01b0bc --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -0,0 +1,78 @@ +import { Pressable, Text, View } from 'react-native' +import { GitBranch } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-source-control-styles' +import { MobileSourceControlPrChip } from './MobileSourceControlPrChip' +import type { MobilePrChipSummary } from './mobile-pr-chip-summary' +import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort' + +type Props = { + branchLabel: string + syncLabel: string | null + unstagedCount: number + stagedCount: number + branchCount: number + conflictOperation: string | null + // True while any serial git IO is in flight — disables Abort so ops don't race. + conflictBusy: boolean + // True only while abort-merge / abort-rebase itself is running (label accuracy). + conflictAborting: boolean + onAbortConflict: (operation: string) => void + // The PR chip is shown only on repos with a hosted-review remote; null hides it. + prChip: MobilePrChipSummary | null + onOpenPr: () => void +} + +// Persistent card at the top of every hub segment: branch identity, sync/counts, +// conflict state, and the PR chip. Shared so PR/History see the same status the +// Changes lens does without re-deriving it. +export function MobileSourceControlBranchCard({ + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchCount, + conflictOperation, + conflictBusy, + conflictAborting, + onAbortConflict, + prChip, + onOpenPr +}: Props) { + const showConflict = conflictOperation !== null && conflictOperation !== 'unknown' + return ( + + + + + + {branchLabel} + + + {syncLabel ? {syncLabel} : null} + + + {unstagedCount} changed + {stagedCount} staged + {branchCount > 0 ? {branchCount} on branch : null} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [styles.abortButton, pressed && styles.abortPressed]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} + + {prChip ? : null} + + ) +} diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 3f1309fad97..a28504a5c53 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,5 +1,5 @@ import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' -import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' +import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' import { MobileCommitFailurePanel } from './MobileCommitFailurePanel' @@ -7,12 +7,13 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows' import type { MobileSourceControlState } from './use-mobile-source-control-state' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' type Props = { state: MobileSourceControlState } -// The ready-state body: summary card, changed-files list, and commit bar. +// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. export function MobileSourceControlContent({ state }: Props) { const { insets, @@ -29,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) { keyboardLift, openingPath, openingBranchPath, - status, sections, - branchEntries, hasVisibleChanges, stageablePaths, unstageablePaths, stagedCount, - unstagedCount, - branchLabel, - syncLabel, primaryAction, createPrAction, stageAll, unstageAll, generateCommitMessage, cancelGenerateCommitMessage, - abortConflictOperation, openFile, openBranchDiff, runGitAction @@ -66,41 +61,7 @@ export function MobileSourceControlContent({ state }: Props) { Reconnecting to desktop... ) : null} - - - - - - {branchLabel} - - - {syncLabel ? {syncLabel} : null} - - - {unstagedCount} changed - {stagedCount} staged - {branchEntries.length > 0 ? ( - {branchEntries.length} on branch - ) : null} - {status && status.conflictOperation !== 'unknown' ? ( - - {status.conflictOperation} - {(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={busyAction !== null} - onPress={() => void abortConflictOperation(status.conflictOperation)} - > - - {busyAction === `abort-${status.conflictOperation}` - ? 'Aborting…' - : `Abort ${status.conflictOperation}`} - - - )} - - ) : null} - + {commitFailureRecovery ? ( ) : ( void onRefresh: () => void + // When set (PR segment ready with a host URL), show open-on-web flush-right of + // the title so the control stays visible while the PR body scrolls. + onOpenPrWeb?: () => void + prNumber?: number | null } export function MobileSourceControlHeader({ @@ -16,7 +20,9 @@ export function MobileSourceControlHeader({ worktreeLabel, ioBusy, onBack, - onRefresh + onRefresh, + onOpenPrWeb, + prNumber = null }: Props) { return ( @@ -40,6 +46,21 @@ export function MobileSourceControlHeader({ {worktreeLabel} + {onOpenPrWeb ? ( + [styles.refreshButton, pressed && styles.refreshButtonPressed]} + onPress={onOpenPrWeb} + hitSlop={8} + accessibilityRole="link" + accessibilityLabel={ + prNumber != null + ? `Open pull request #${prNumber} on the web` + : 'Open pull request on the web' + } + > + + + ) : null} [ styles.refreshButton, diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 256cb9c0ab6..332f62312f1 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { colors } from '../theme/mobile-theme' @@ -6,7 +7,17 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a import { MobileSourceControlHeader } from './MobileSourceControlHeader' import { MobileSourceControlContent } from './MobileSourceControlContent' import { MobileSourceControlModals } from './MobileSourceControlModals' +import { MobileSourceControlSegments } from './MobileSourceControlSegments' +import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard' +import { MobileGitHistoryList } from './MobileGitHistoryList' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { SourceControlHubTab } from './mobile-source-control-hub-tab' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' +import { isMobileConflictAborting } from './mobile-source-control-conflict-abort' +import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' +import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' +import { openMobilePrUrl } from '../components/MobilePrComposeSheet' export type MobileSourceControlPanelProps = { hostId: string @@ -15,6 +26,8 @@ export type MobileSourceControlPanelProps = { /** Where the panel was launched from; drives the file-open dismissal path. */ origin?: string embedded?: boolean + /** Initial hub segment (from the route's `tab` deep-link param). */ + initialTab?: SourceControlHubTab onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void @@ -26,10 +39,48 @@ export function MobileSourceControlPanel({ name = '', origin = '', embedded = false, + initialTab = 'changes', onRequestClose, onFileOpenStart, onOpenedFileDiff }: MobileSourceControlPanelProps) { + const [activeTab, setActiveTab] = useState(initialTab) + // Track first visit so Changes/History keep scroll state across segment switches + // without paying the mount cost until the user actually opens them. PR unmounts + // when inactive (WebViews / comment tree) but its controller state stays for the chip. + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([initialTab]) + ) + const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0) + + // Deep-link / push with a different `tab` param should adopt the new segment + // (expo-router can reuse the screen instance when only query params change). + useEffect(() => { + setActiveTab(initialTab) + setVisitedTabs((prev) => { + if (prev.has(initialTab)) { + return prev + } + const next = new Set(prev) + next.add(initialTab) + return next + }) + }, [initialTab]) + + const selectTab = useCallback((tab: SourceControlHubTab) => { + setActiveTab(tab) + setVisitedTabs((prev) => { + if (prev.has(tab)) { + return prev + } + const next = new Set(prev) + next.add(tab) + return next + }) + }, []) + const openHistoryTab = useCallback(() => selectTab('history'), [selectTab]) + const openPrTab = useCallback(() => selectTab('pr'), [selectTab]) + const state = useMobileSourceControlState({ hostId, worktreeId, @@ -38,12 +89,15 @@ export function MobileSourceControlPanel({ embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory: openHistoryTab }) const actionSheetActions = useMobileSourceControlActionSheet(state) const { + client, connState, forceReconnect, + insets, router, setRootRef, worktreeLabel, @@ -51,25 +105,202 @@ export function MobileSourceControlPanel({ busyAction, openingPath, openingBranchPath, - loadStatus + loadStatus, + status, + branchCompareResult, + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchEntries, + abortConflictOperation } = state const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null + const ready = screenState.kind === 'ready' + + // One PR controller feeds both the branch-card chip and the Pull Request + // segment, so the chip's rollup can never disagree with the checks list it + // links to. Branch + head come from the already-loaded git.status — no second + // status read. The chip loads independently, so it never blocks the file list. + // Keep last-known identity across a transient status unload (disconnect / + // failed refresh) so the controller does not wipe ready → hidden → cold start. + // Head SHA matches the review path: status.head ?? branchCompare headOid. + const lastPrBranchRef = useRef(null) + const lastPrHeadRef = useRef(null) + useEffect(() => { + lastPrBranchRef.current = null + lastPrHeadRef.current = null + }, [worktreeId]) + const statusBranch = status?.branch ?? null + const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null + // Write last-known identity in an effect, not the render body: a discarded + // concurrent render must not leave the fallback holding a never-committed value. + useEffect(() => { + if (statusBranch) { + lastPrBranchRef.current = statusBranch + } + if (statusHead) { + lastPrHeadRef.current = statusHead + } + }, [statusBranch, statusHead]) + const prBranch = statusBranch ?? lastPrBranchRef.current + const prHeadSha = statusHead ?? lastPrHeadRef.current + const prController = useMobilePrSidebarController({ + client, + connState, + worktreeId, + branch: prBranch, + headSha: prHeadSha + }) + const isHostedRepo = prController.prSidebarIsGithubRepo + const prSidebarKind = prController.prSidebarState.kind + const refetchPr = prController.refetchPRSidebar + const ensurePrDetails = prController.ensurePrSidebarDetails + // Refs so tab effects do not re-fire when headSha recreates load() (soft refresh). + const refetchPrRef = useRef(refetchPr) + refetchPrRef.current = refetchPr + const ensurePrDetailsRef = useRef(ensurePrDetails) + ensurePrDetailsRef.current = ensurePrDetails + + // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until + // the Pull Request segment is open — opening SC to stage must not pull details. + useEffect(() => { + if (activeTab === 'pr') { + return + } + if (prBranch && isHostedRepo && prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: false }) + } + }, [activeTab, prBranch, isHostedRepo, prSidebarKind]) + + // Number of the ready PR whose phase-2 details are still missing (null when + // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a + // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its + // comments on a forever-spinner (a stale ensure bails on the number mismatch). + const prDetailsMissingFor = + prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null + ? prController.prSidebarState.data.pr.number + : null + + // PR segment: full load or phase-2 fill-in. Body only mounts while active. + // Guarded (and keyed) on prBranch so a branch that arrives while the segment is + // already open — e.g. mounted on a detached HEAD, then checkout — still loads; + // kind stays 'hidden' through that transition, so kind alone can't re-fire this. + useEffect(() => { + if (activeTab !== 'pr' || !isHostedRepo || !prBranch) { + return + } + if (prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: true }) + return + } + if (prDetailsMissingFor != null) { + void ensurePrDetailsRef.current() + } + }, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor]) + + const prChip = useMemo(() => { + // No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared + // state stays 'hidden' — which the chip would render as a forever spinner. + // Hide the chip instead; the Pull Request segment shows "branch unavailable". + if (!isHostedRepo || !prBranch) { + return null + } + const commentCount = + prController.prSidebarState.kind === 'ready' + ? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments) + : null + return buildMobilePrChipSummary(prController.prSidebarState, commentCount) + }, [isHostedRepo, prBranch, prController.prSidebarState]) + + // Design: refresh the active segment's body work, plus git.status for the shared + // branch card (counts/sync stay honest even while on History). Preserve ready + // status on a failed refresh so PR chip identity is not wiped to hidden. + const onRefresh = useCallback(() => { + void loadStatus({ preserveReadyOnFailure: true }) + if (activeTab === 'history') { + setHistoryRefreshNonce((n) => n + 1) + return + } + if (!isHostedRepo) { + return + } + if (activeTab === 'pr') { + void refetchPr({ includeDetails: true }) + return + } + // Changes: light chip refresh so the branch card stays current without comments. + void refetchPr({ includeDetails: false }) + }, [activeTab, isHostedRepo, loadStatus, refetchPr]) // Embedded mode docks beside the terminal: close the dock instead of popping // a route, and skip the full-screen safe-area chrome (the dock column owns it). - // Fall back to router.back() when embedded without a close handler so the button - // never silently no-ops. const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back() + // Chromeless PR body has no panel header — surface open-on-web on the hub chrome + // while the Pull Request segment is active (same affordance as the old /pr route). + const prWebUrl = + activeTab === 'pr' && + prController.prSidebarState.kind === 'ready' && + prController.prSidebarState.data.pr.url + ? prController.prSidebarState.data.pr.url + : null + const prWebNumber = + prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null const header = ( void loadStatus()} + onRefresh={onRefresh} + onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined} + prNumber={prWebNumber} /> ) + const statusGate = + screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( + + + {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} + + {screenState.message} + {screenState.kind === 'error' ? ( + { + // Why: retrying the request is useless while the transport's + // reconnect loop is parked at its give-up cap — revive the + // connection instead (issue #5049). loadStatus re-runs via + // its connState effect once the new client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadStatus() + }} + > + Retry + + ) : null} + + ) : null + + // History only needs the RPC client — do not block it behind git.status. + // Changes/PR need status (branch, file list, head SHA), so they stay gated. + const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes')) + const showHistory = activeTab === 'history' || visitedTabs.has('history') + // Why: only mount the PR body while its segment is active. Keep-mounting retained + // Mermaid WebViews and re-rendered the comment tree on every commit keystroke. + // Controller + chip state still live for instant re-open without a full cold start. + const showPrBody = ready && activeTab === 'pr' + const conflictOperation = status?.conflictOperation ?? null + const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation) + return ( {embedded ? ( @@ -80,38 +311,64 @@ export function MobileSourceControlPanel({ )} - {screenState.kind === 'loading' ? ( - - + + + {ready ? ( + void abortConflictOperation(operation)} + prChip={prChip} + onOpenPr={openPrTab} + /> + ) : null} + + {showChanges ? ( + + - ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( - - - {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} - - {screenState.message} - {screenState.kind === 'error' ? ( - { - // Why: retrying the request is useless while the transport's - // reconnect loop is parked at its give-up cap — revive the - // connection instead (issue #5049). loadStatus re-runs via - // its connState effect once the new client connects. - if (connState !== 'connected' && hostId) { - void forceReconnect(hostId) - return - } - void loadStatus() - }} - > - Retry - - ) : null} + ) : activeTab === 'changes' ? ( + statusGate + ) : null} + + {showPrBody ? ( + + - ) : ( - - )} + ) : activeTab === 'pr' ? ( + statusGate + ) : null} + + {showHistory ? ( + + + + ) : null} diff --git a/mobile/src/source-control/MobileSourceControlPrChip.tsx b/mobile/src/source-control/MobileSourceControlPrChip.tsx new file mode 100644 index 00000000000..1165aae8b9b --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlPrChip.tsx @@ -0,0 +1,121 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + AlertTriangle, + Check, + ChevronRight, + CircleDot, + GitPullRequest, + MessageSquare, + X +} from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary' + +type Props = { + summary: MobilePrChipSummary + onPress: () => void +} + +// The glanceable PR status line on the branch card. Tapping it switches to the +// Pull Request segment. Rendered only when the repo supports hosted review — the +// parent gates on that, so this component always has something meaningful to show. +export function MobileSourceControlPrChip({ summary, onPress }: Props) { + return ( + [hubStyles.chip, pressed && hubStyles.chipPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={chipAccessibilityLabel(summary)} + > + + + + {summary.kind === 'loading' ? ( + <> + + + Loading pull request… + + + ) : summary.kind === 'none' ? ( + <> + Create pull request + + + + ) : summary.kind === 'unavailable' ? ( + <> + + {summary.message} + + + + ) : ( + <> + #{summary.number} + + + {summary.stateLabel} + + + + {summary.commentCount != null && summary.commentCount > 0 ? ( + + + {summary.commentCount} + + ) : null} + + + + )} + + ) +} + +function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) { + const color = statusColor(rollup.token) + return ( + + + {rollup.text} + + ) +} + +function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) { + const size = 13 + const strokeWidth = 2.3 + switch (kind) { + case 'conflict': + return + case 'failing': + return + case 'running': + return + case 'passed': + return + case 'none': + return null + } +} + +function chipAccessibilityLabel(summary: MobilePrChipSummary): string { + switch (summary.kind) { + case 'loading': + return 'Loading pull request' + case 'none': + return 'Create pull request' + case 'unavailable': + return `Pull request unavailable: ${summary.message}` + case 'ready': { + const comments = + summary.commentCount != null && summary.commentCount > 0 + ? `, ${summary.commentCount} unresolved comments` + : '' + return `Pull request #${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.` + } + } +} diff --git a/mobile/src/source-control/MobileSourceControlSegments.tsx b/mobile/src/source-control/MobileSourceControlSegments.tsx new file mode 100644 index 00000000000..89564aca417 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlSegments.tsx @@ -0,0 +1,45 @@ +import { Pressable, Text, View } from 'react-native' +import { + SOURCE_CONTROL_HUB_TABS, + SOURCE_CONTROL_HUB_TAB_LABELS, + type SourceControlHubTab +} from './mobile-source-control-hub-tab' +import { hubStyles } from './mobile-source-control-hub-styles' + +type Props = { + active: SourceControlHubTab + onSelect: (tab: SourceControlHubTab) => void +} + +// The hub's top-level lens switcher. Switching is local state (no route push) so +// scroll position and the shared branch card persist across Changes/PR/History. +export function MobileSourceControlSegments({ active, onSelect }: Props) { + return ( + + {SOURCE_CONTROL_HUB_TABS.map((tab) => { + const isActive = tab === active + return ( + [ + hubStyles.segment, + isActive && hubStyles.segmentActive, + pressed && !isActive && hubStyles.segmentPressed + ]} + onPress={() => onSelect(tab)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + > + + {SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + + + ) + })} + + ) +} diff --git a/mobile/src/source-control/mobile-pr-chip-summary.test.ts b/mobile/src/source-control/mobile-pr-chip-summary.test.ts new file mode 100644 index 00000000000..88186a06ea0 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' + +function pr(overrides: Partial = {}): PRInfo { + return { + number: 7701, + title: 'Make center split more visible', + state: 'open', + url: 'https://example.test/pr/7701', + checksStatus: 'success', + updatedAt: '2026-07-07T00:00:00Z', + mergeable: 'MERGEABLE', + ...overrides + } +} + +function check( + conclusion: PRCheckDetail['conclusion'], + status: PRCheckDetail['status'] = 'completed' +): PRCheckDetail { + return { name: `check-${conclusion}-${status}`, status, conclusion, url: null } +} + +function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState { + return { kind: 'ready', data: { pr: prInfo, checks, details: null } } +} + +describe('buildMobilePrChipSummary', () => { + it('maps non-ready states', () => { + expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' }) + expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({ + kind: 'unavailable', + message: 'net' + }) + expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({ + kind: 'unavailable', + message: 'auth' + }) + }) + + it('surfaces the PR number and state badge', () => { + const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')])) + expect(summary.kind).toBe('ready') + if (summary.kind !== 'ready') { + return + } + expect(summary.number).toBe(7701) + expect(summary.stateLabel).toBe('Draft') + }) + + it('rolls up passed checks as passed/total', () => { + const summary = buildMobilePrChipSummary( + ready(pr(), [check('success'), check('success'), check('skipped')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' }) + }) + + it('prefers failing over running and passing', () => { + const summary = buildMobilePrChipSummary( + ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' }) + }) + + it('shows running when nothing has failed yet', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' }) + }) + + it('lets a merge conflict win over green checks', () => { + const summary = buildMobilePrChipSummary( + ready(pr({ mergeable: 'CONFLICTING' }), [check('success')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup.kind).toBe('conflict') + }) + + it('reports no checks when the list is empty', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' }) + }) + + it('passes through the unresolved comment count', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.commentCount).toBe(3) + }) +}) + +describe('countUnresolvedReviewThreads', () => { + function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'x', + createdAt: '2026-07-07T00:00:00Z', + url: '', + ...overrides + } + } + + it('returns null when details have not loaded', () => { + expect(countUnresolvedReviewThreads(null)).toBeNull() + expect(countUnresolvedReviewThreads(undefined)).toBeNull() + }) + + it('counts each unresolved thread once and ignores resolved threads', () => { + const comments = [ + comment({ id: 1, threadId: 't1', isResolved: false }), + comment({ id: 2, threadId: 't1', isResolved: false }), + comment({ id: 3, threadId: 't2', isResolved: true }), + comment({ id: 4, threadId: 't3' }), + comment({ id: 5 }) + ] + expect(countUnresolvedReviewThreads(comments)).toBe(2) + }) +}) diff --git a/mobile/src/source-control/mobile-pr-chip-summary.ts b/mobile/src/source-control/mobile-pr-chip-summary.ts new file mode 100644 index 00000000000..87f54d43472 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.ts @@ -0,0 +1,104 @@ +import type { PRComment } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { + prStateBadge, + summarizePRChecks, + type MobileStatusToken +} from '../components/pr-sidebar/pr-checks-presentation' + +// Pure derivation of the branch-card PR chip from the shared PR sidebar state. +// No React/native imports so the rollup precedence is unit-testable (KTD5). The +// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's +// check rollup can never disagree with the checks list it links to. + +// The single check-rollup token the chip shows. Exactly one wins, by precedence: +// merge conflict > failing > running > passed > no checks. Worst-actionable-first +// so a red/amber signal is never hidden behind a green count. +export type MobilePrChipRollup = + | { kind: 'conflict'; text: string; token: MobileStatusToken } + | { kind: 'failing'; text: string; token: MobileStatusToken } + | { kind: 'running'; text: string; token: MobileStatusToken } + | { kind: 'passed'; text: string; token: MobileStatusToken } + | { kind: 'none'; text: string; token: MobileStatusToken } + +export type MobilePrChipSummary = + | { kind: 'loading' } + // GitHub repo, but this branch has no open/linked PR — the chip becomes a + // "create pull request" affordance that routes to the PR segment's composer. + | { kind: 'none' } + // A permanent (auth/permission) or transient failure loading PR data. The chip + // degrades to a muted, tappable line rather than vanishing. + | { kind: 'unavailable'; message: string } + | { + kind: 'ready' + number: number + stateLabel: string + stateToken: MobileStatusToken + rollup: MobilePrChipRollup + // Unresolved review-comment count, when the deferred details payload has + // loaded; null while it is still loading or unavailable. + commentCount: number | null + } + +// Unresolved review threads (the "💬 n" count). Counts each inline thread once by +// threadId when it is not resolved; top-level conversation comments (no threadId) +// are not review threads and don't count. Returns null when details haven't loaded. +export function countUnresolvedReviewThreads( + comments: PRComment[] | null | undefined +): number | null { + if (!comments) { + return null + } + const unresolved = new Set() + for (const comment of comments) { + if (comment.threadId && comment.isResolved !== true) { + unresolved.add(comment.threadId) + } + } + return unresolved.size +} + +export function buildMobilePrChipSummary( + state: PrSidebarState, + commentCount: number | null = null +): MobilePrChipSummary { + switch (state.kind) { + case 'hidden': + case 'loading': + return { kind: 'loading' } + case 'none': + return { kind: 'none' } + case 'error': + case 'blocked': + return { kind: 'unavailable', message: state.message } + case 'ready': { + const badge = prStateBadge(state.data.pr.state) + return { + kind: 'ready', + number: state.data.pr.number, + stateLabel: badge.label, + stateToken: badge.token, + rollup: buildChipRollup(state), + commentCount + } + } + } +} + +function buildChipRollup(state: Extract): MobilePrChipRollup { + // Conflicts win: the checks may be green, but the PR still can't merge. + if (state.data.pr.mergeable === 'CONFLICTING') { + return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' } + } + const checks = summarizePRChecks(state.data.checks) + if (checks.failed > 0) { + return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' } + } + if (checks.pending > 0) { + return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' } + } + if (checks.passed > 0) { + return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' } + } + return { kind: 'none', text: 'No checks', token: 'textSecondary' } +} diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts new file mode 100644 index 00000000000..fa9e40a4746 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileConflictAborting, + mobileConflictAbortLabel +} from './mobile-source-control-conflict-abort' + +describe('isMobileConflictAborting', () => { + it('is true only for the matching abort action', () => { + expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true) + expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true) + }) + + it('is false for other busy actions (stage/commit must not look like abort)', () => { + expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false) + expect(isMobileConflictAborting('commit', 'rebase')).toBe(false) + expect(isMobileConflictAborting(null, 'merge')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false) + }) +}) + +describe('mobileConflictAbortLabel', () => { + it('shows Aborting only while abort is in flight', () => { + expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…') + expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge') + expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.ts new file mode 100644 index 00000000000..ac71bcbf9ed --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.ts @@ -0,0 +1,18 @@ +// Pure helpers for the branch-card conflict Abort control. Kept free of React so +// the busy-label rule (abort-in-flight only) is unit-testable. + +/** True while git.abortMerge / git.abortRebase is the active serial action. */ +export function isMobileConflictAborting( + busyAction: string | null, + conflictOperation: string | null +): boolean { + if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') { + return false + } + return busyAction === `abort-${conflictOperation}` +} + +/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */ +export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string { + return aborting ? 'Aborting…' : `Abort ${conflictOperation}` +} diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts new file mode 100644 index 00000000000..683fa3a1dcc --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -0,0 +1,126 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +// Styles for the hub's segmented control and the branch-card PR chip. Split from +// mobile-source-control-styles.ts so neither file crosses the line limit. +export const hubStyles = StyleSheet.create({ + segments: { + flexDirection: 'row', + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + padding: 3, + borderRadius: radii.button + 2, + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + gap: 2 + }, + segment: { + flex: 1, + minHeight: 34, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xs + }, + segmentActive: { + backgroundColor: colors.bgPanel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + segmentPressed: { + opacity: 0.7 + }, + segmentText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + segmentTextActive: { + color: colors.textPrimary + }, + // The PR chip sits below the count row inside the branch card, separated by a + // hairline so it reads as a distinct, tappable status line. + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + chipPressed: { + opacity: 0.7 + }, + chipIcon: { + width: 18, + alignItems: 'center' + }, + chipNumber: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '700' + }, + statePill: { + paddingHorizontal: spacing.sm, + paddingVertical: 1, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth + }, + statePillText: { + fontSize: typography.metaSize, + fontWeight: '700' + }, + rollup: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + rollupText: { + fontSize: typography.metaSize, + fontWeight: '600' + }, + comment: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + commentText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + // Pushes the chevron to the trailing edge without a fixed-width spacer. + chipSpacer: { + flex: 1, + minWidth: spacing.sm + }, + chipCreateText: { + color: colors.accentBlue, + fontSize: typography.bodySize, + fontWeight: '600' + }, + chipMutedText: { + flex: 1, + color: colors.textMuted, + fontSize: typography.metaSize + }, + // Wraps the Changes-only controls (commit-failure/error notice, create-PR entry, + // bulk Stage/Unstage row) that used to live inside the summary card, now that the + // card is shared across segments and holds only branch status. + changesControls: { + paddingHorizontal: spacing.lg, + marginTop: spacing.xs + }, + // Fills the remaining space below the header/segments/card so each segment's + // scroll view (SectionList / PR sidebar / history list) expands and scrolls. + tabBody: { + flex: 1 + }, + // Keep a previously-visited segment mounted (scroll + fetch state) without + // participating in layout while another segment is active. + tabBodyHidden: { + display: 'none' + } +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.test.ts b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts new file mode 100644 index 00000000000..4caf135cf13 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab' + +describe('parseSourceControlHubTab', () => { + it('accepts each known tab', () => { + for (const tab of SOURCE_CONTROL_HUB_TABS) { + expect(parseSourceControlHubTab(tab)).toBe(tab) + } + }) + + it('reads the first element of an array param', () => { + expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr') + }) + + it('falls back to changes for unknown, empty, or missing values', () => { + expect(parseSourceControlHubTab('nope')).toBe('changes') + expect(parseSourceControlHubTab('')).toBe('changes') + expect(parseSourceControlHubTab(undefined)).toBe('changes') + expect(parseSourceControlHubTab(null)).toBe('changes') + expect(parseSourceControlHubTab([])).toBe('changes') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts new file mode 100644 index 00000000000..e243032a58c --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -0,0 +1,29 @@ +// The three lenses of the mobile Source Control hub. Kept as a pure module (no +// React/native imports) so tab parsing is unit-testable and the deep-link `tab` +// query param and the segmented control share one source of truth. + +export type SourceControlHubTab = 'changes' | 'pr' | 'history' + +export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [ + 'changes', + 'pr', + 'history' +] as const + +export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = { + changes: 'Changes', + pr: 'Pull Request', + history: 'History' +} + +// Normalize a route param (possibly an array from expo-router, possibly unknown) +// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid +// tab fall back rather than render a blank body. +export function parseSourceControlHubTab( + value: string | string[] | undefined | null +): SourceControlHubTab { + const first = Array.isArray(value) ? value[0] : value + return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab) + ? (first as SourceControlHubTab) + : 'changes' +} diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 29892344779..026a706a5f9 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -45,6 +45,8 @@ type Params = { setCreatedPrUrl: (next: string | null) => void setCreatedPrWarning: (next: string | null) => void recordCommitFailure: RecordMobileCommitFailure + // Hub override: switch to the History segment instead of pushing the route. + onOpenHistory?: () => void } // All git workflow + action-sheet runners for the source-control panel. Split @@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) { setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory } = params const runGitWorkflow = useCallback( @@ -248,14 +251,24 @@ export function useMobileSourceControlRunners(params: Params) { const openHistory = useCallback(() => { setShowActionSheet(false) - if (hostId && worktreeId) { - router.push( - `/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters< - typeof router.push - >[0] - ) + // Inside the hub, History is a segment — switch to it rather than pushing a + // route. Fallback pushes the hub with `tab=history` (not the redirecting + // /history route) so deep links land in one hop. + if (onOpenHistory) { + onOpenHistory() + return } - }, [hostId, router, setShowActionSheet, worktreeId]) + if (hostId && worktreeId) { + router.push({ + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { + hostId, + worktreeId, + tab: 'history' + } + } as Parameters[0]) + } + }, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId]) // Switch to a local branch, then reload status. const checkoutBranch = useCallback( diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index cc69b89059c..c9a8ca406b0 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -42,6 +42,9 @@ export type MobileSourceControlStateParams = { onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void + // When the panel runs inside the hub, "History" switches the segment instead of + // pushing the standalone route. Absent for the standalone/dock usage. + onOpenHistory?: () => void } export function useMobileSourceControlState(params: MobileSourceControlStateParams) { @@ -53,7 +56,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory } = params const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) @@ -197,7 +201,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory }) const createPrAction = useMobileSourceControlCreatePrAction({ client, From 6731773e4ee56e269668386c38e9c683299b2792 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:30:23 -0700 Subject: [PATCH 18/25] Route provider account surfaces through the active Remote Orca Server (#7999) Co-authored-by: Orca --- .../ipc/notification-authorization-status.ts | 1 + .../components/settings/AccountsPane.test.tsx | 35 ++ .../src/components/settings/AccountsPane.tsx | 259 +++++++++------ .../src/components/status-bar/StatusBar.tsx | 120 +++++-- .../status-bar-runtime-groups.test.ts | 97 +++++- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- .../runtime-provider-accounts-client.test.ts | 302 ++++++++++++++++++ .../runtime-provider-accounts-client.ts | 227 +++++++++++++ 12 files changed, 960 insertions(+), 136 deletions(-) create mode 100644 src/renderer/src/runtime/runtime-provider-accounts-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-provider-accounts-client.ts diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts index bdaba67efae..d51f97ede17 100644 --- a/src/main/ipc/notification-authorization-status.ts +++ b/src/main/ipc/notification-authorization-status.ts @@ -80,6 +80,7 @@ function runStatusHelper(helperPath: string): Promise { ) expect(markup).not.toContain('This device') }) + + it('scopes account copy to the active remote server and disables local sign-in actions', () => { + // Note: static SSR markup reads the store's initial state (zustand v5), so + // this exercises the fallback server label; the named-server path is + // covered by live validation against a paired server. + const markup = renderPane( + { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }, + { wslSupportedPlatform: true } + ) + + expect(markup).toContain( + 'Showing accounts managed by the remote server. Add or re-authenticate accounts on that server.' + ) + // The WSL account-location toggle is a local concern; a remote owner hides it. + expect(markup).not.toContain('aria-label="Account location"') + const addAccountIndex = markup.indexOf('Add Account') + expect(addAccountIndex).toBeGreaterThan(0) + expect(markup.slice(markup.lastIndexOf(' { + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('Showing accounts for this device. New accounts are added there.') + const addAccountIndex = markup.indexOf('Add Account') + expect(addAccountIndex).toBeGreaterThan(0) + expect( + markup.slice(markup.lastIndexOf(' s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) - const accountRuntime = getSelectedAccountRuntime( + const localAccountRuntime = getSelectedAccountRuntime( settings, wslSupportedPlatform, wslAvailable, wslDistros, wslCapabilitiesLoading ) + // Why: with a Remote Orca Server active the server owns provider accounts + // (see #7973); every list/select/remove below must scope to it, not host/WSL. + const isRemoteAccountScope = hasRemoteProviderAccountOwner(settings) + const activeRuntimeEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null + const remoteServerLabel = isRemoteAccountScope + ? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId) + ?.name ?? + translate('auto.components.settings.AccountsPane.remoteServerFallback', 'the remote server')) + : null + const accountRuntime: LocalAccountRuntime = isRemoteAccountScope + ? { runtime: 'host', label: remoteServerLabel ?? '' } + : localAccountRuntime // Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing. const accountRuntimeSentenceLabel = - accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') + !isRemoteAccountScope && + accountRuntime.runtime === 'host' && + !navigator.userAgent.includes('Windows') ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}` : accountRuntime.label + const localAccountRuntimeSentenceLabel = + localAccountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') + ? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}` + : localAccountRuntime.label const [codexAccounts, setCodexAccounts] = useState({ accounts: [], @@ -384,15 +413,18 @@ export function AccountsPane({ ) const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime) - const activeCodexAuthWarning = codexAccountsLoaded - ? getCodexAccountAuthWarning({ - limits: codexRateLimits, - target: codexRateLimitTarget, - runtime: accountRuntime, - activeAccountId: activeCodexAccountId, - accountId: activeCodexAccountId - }) - : null + // Why: the auth warning is derived from the desktop's own rate-limit poll; + // with a remote owner it would misattribute local auth state to the server. + const activeCodexAuthWarning = + codexAccountsLoaded && !isRemoteAccountScope + ? getCodexAccountAuthWarning({ + limits: codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: activeCodexAccountId + }) + : null const systemCodexNeedsReauthentication = activeCodexAccountId === null && Boolean(activeCodexAuthWarning) const accountRuntimeUnavailable = @@ -477,21 +509,22 @@ export function AccountsPane({ }, []) useEffect(() => { - let stale = false - - const loadCodexAccounts = async (): Promise => { - try { - const nextCodex = await window.api.codexAccounts.list() - if (!stale) { - setCodexAccounts(nextCodex) + // Why: remote snapshots stream usage refreshes after the synchronous ready + // message, so the watcher stays open for the pane's lifetime; the local + // path resolves once and the close() is a no-op. + const watcher = watchProviderAccounts( + { activeRuntimeEnvironmentId }, + { + onSnapshot: (snapshot) => { + setCodexAccounts(snapshot.codex) + setClaudeAccounts(snapshot.claude) setCodexAccountsLoaded(true) - } - } catch (error) { - if (!stale) { + }, + onError: (error) => { toast.error( translate( - 'auto.components.settings.AccountsPane.b8c2905c2b', - 'Could not load Codex accounts.' + 'auto.components.settings.AccountsPane.loadAccountsFailed', + 'Could not load provider accounts.' ), { description: String((error as Error)?.message ?? error) @@ -499,46 +532,27 @@ export function AccountsPane({ ) } } - } - - const loadClaudeAccounts = async (): Promise => { - try { - const nextClaude = await window.api.claudeAccounts.list() - if (!stale) { - setClaudeAccounts(nextClaude) - } - } catch (error) { - if (!stale) { - toast.error( - translate( - 'auto.components.settings.AccountsPane.9107406589', - 'Could not load Claude accounts.' - ), - { - description: String((error as Error)?.message ?? error) - } - ) - } - } - } - - void loadCodexAccounts() - void loadClaudeAccounts() + ) return () => { - stale = true + watcher.close() } - }, []) + }, [activeRuntimeEnvironmentId]) const syncCodexAccounts = async (next: CodexRateLimitAccountsState): Promise => { setCodexAccounts(next) setCodexAccountsLoaded(true) - await fetchSettings() + // Why: remote mutations never change local GlobalSettings account fields. + if (!isRemoteAccountScope) { + await fetchSettings() + } } const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise => { setClaudeAccounts(next) - await fetchSettings() + if (!isRemoteAccountScope) { + await fetchSettings() + } } const formatAccountTimestamp = (timestamp: number): string => { @@ -735,6 +749,7 @@ export function AccountsPane({ const visibleSections = [ wslSupportedPlatform && + !isRemoteAccountScope && matchesSettingsSearch(searchQuery, getAccountsLocationSearchEntries()) ? (
{accountRuntimeControls} @@ -770,11 +785,17 @@ export function AccountsPane({ {translate('auto.components.settings.AccountsPane.94d351af4a', 'Accounts')}

- {translate( - 'auto.components.settings.AccountsPane.c0a52abfc5', - 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAccounts', + 'Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.c0a52abfc5', + 'Showing accounts for {{value0}}. New accounts are added there.', + { value0: accountRuntimeSentenceLabel } + )}

@@ -790,7 +811,12 @@ export function AccountsPane({ ) } disabled={ - claudeAction !== 'idle' || wslCapabilitiesLoading || accountRuntimeUnavailable + // Why: interactive `claude login` needs a desktop browser and + // would authenticate against this device, not the server. + isRemoteAccountScope || + claudeAction !== 'idle' || + wslCapabilitiesLoading || + accountRuntimeUnavailable } className="gap-1.5" > @@ -820,7 +846,7 @@ export function AccountsPane({ type="button" onClick={() => void runClaudeAccountAction('select:system', () => - window.api.claudeAccounts.select({ + selectClaudeProviderAccount(settings, { accountId: null, runtime: accountRuntime.runtime, wslDistro: accountRuntime.wslDistro @@ -862,11 +888,17 @@ export function AccountsPane({ {visibleClaudeAccounts.length === 0 ? (
- {translate( - 'auto.components.settings.AccountsPane.3fe7862418', - "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteEmptyClaudeAccounts', + 'No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.3fe7862418', + "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", + { value0: accountRuntimeSentenceLabel } + )}
) : ( visibleClaudeAccounts.map((account) => { @@ -888,7 +920,7 @@ export function AccountsPane({ type="button" onClick={() => void runClaudeAccountAction(`select:${account.id}`, () => - window.api.claudeAccounts.select({ + selectClaudeProviderAccount(settings, { accountId: account.id, runtime: account.managedAuthRuntime ?? 'host', wslDistro: account.wslDistro ?? null @@ -904,7 +936,7 @@ export function AccountsPane({ variant="outline" className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70" > - {getClaudeAccountRuntimeLabel(account)} + {getClaudeAccountRuntimeLabel(account, accountRuntime.label)} {isActive ? ( {isReauthing ? ( @@ -984,10 +1016,16 @@ export function AccountsPane({ )}

- {translate( - 'auto.components.settings.AccountsPane.340d6f7a85', - 'Each account keeps its own local sign-in context in Orca. Account auth stays on this device.' - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAuthContext', + 'Each account keeps its own sign-in context on {{value0}}.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.340d6f7a85', + 'Each account keeps its own local sign-in context in Orca. Account auth stays on this device.' + )}

@@ -1036,11 +1074,17 @@ export function AccountsPane({ {translate('auto.components.settings.AccountsPane.94d351af4a', 'Accounts')}

- {translate( - 'auto.components.settings.AccountsPane.c0a52abfc5', - 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAccounts', + 'Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.c0a52abfc5', + 'Showing accounts for {{value0}}. New accounts are added there.', + { value0: accountRuntimeSentenceLabel } + )}

{visibleCodexAccounts.length === 0 ? (
- {translate( - 'auto.components.settings.AccountsPane.b4c9450319', - "No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.", - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteEmptyCodexAccounts', + 'No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.b4c9450319', + "No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.", + { value0: accountRuntimeSentenceLabel } + )}
) : ( visibleCodexAccounts.map((account) => { const isActive = activeCodexAccountId === account.id - const accountAuthWarning = getCodexAccountAuthWarning({ - limits: codexRateLimits, - target: codexRateLimitTarget, - runtime: accountRuntime, - activeAccountId: activeCodexAccountId, - accountId: account.id - }) + // Why: same remote gate as the section-level warning — the + // desktop's rate-limit poll says nothing about server accounts. + const accountAuthWarning = isRemoteAccountScope + ? null + : getCodexAccountAuthWarning({ + limits: codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: account.id + }) const needsReauthentication = Boolean(accountAuthWarning) const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` @@ -1175,7 +1234,7 @@ export function AccountsPane({ type="button" onClick={() => void runCodexAccountAction(`select:${account.id}`, () => - window.api.codexAccounts.select({ + selectCodexProviderAccount(settings, { accountId: account.id, runtime: account.managedHomeRuntime ?? 'host', wslDistro: account.wslDistro ?? null @@ -1191,7 +1250,7 @@ export function AccountsPane({ variant="outline" className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70" > - {getCodexAccountRuntimeLabel(account)} + {getCodexAccountRuntimeLabel(account, accountRuntime.label)} {isActive ? ( {isReauthing ? ( @@ -1339,7 +1398,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.c2aee76420', 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: accountRuntimeSentenceLabel } + { value0: localAccountRuntimeSentenceLabel } )}

@@ -1774,7 +1833,7 @@ export function AccountsPane({ } setRemoveAccountId(null) void runCodexAccountAction(`remove:${accountId}`, () => - window.api.codexAccounts.remove({ accountId }) + removeCodexProviderAccount(settings, accountId) ) }} > @@ -1815,7 +1874,7 @@ export function AccountsPane({ } setRemoveClaudeAccountId(null) void runClaudeAccountAction(`remove:${accountId}`, () => - window.api.claudeAccounts.remove({ accountId }) + removeClaudeProviderAccount(settings, accountId) ) }} > diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 5e333f2ff4b..d3dd012135e 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -72,6 +72,11 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + fetchProviderAccountsSnapshot, + selectClaudeProviderAccount, + selectCodexProviderAccount +} from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' type StatusBarProps = { @@ -132,6 +137,7 @@ export type ClaudeStatusSwitchGroup = { type StatusSwitchGroupOptions = { fallbackWslDistro?: string | null includeFallbackWsl?: boolean + hostLabel?: string } function getHostRuntimeLabel(): string { @@ -157,9 +163,12 @@ function getCodexStatusWslKey(wslDistro: string | null | undefined): string { return trimmed ? trimmed : '__default__' } -function getCodexStatusRuntimeLabel(target: CodexStatusRuntimeTarget): string { +function getCodexStatusRuntimeLabel( + target: CodexStatusRuntimeTarget, + hostLabel = getHostRuntimeLabel() +): string { if (target.runtime === 'host') { - return getHostRuntimeLabel() + return hostLabel } return target.wslDistro ? `WSL ${target.wslDistro}` : 'WSL default' } @@ -261,7 +270,7 @@ export function buildCodexStatusSwitchGroups( const accountsForTarget = getCodexStatusAccountsForTarget(state, target) return { key: getCodexStatusRuntimeKey(target), - label: getCodexStatusRuntimeLabel(target), + label: getCodexStatusRuntimeLabel(target, options.hostLabel), runtimeTarget: target, targets: [ { @@ -420,7 +429,7 @@ export function buildClaudeStatusSwitchGroups( const accountsForTarget = getClaudeStatusAccountsForTarget(state, target) return { key: getCodexStatusRuntimeKey(target), - label: getCodexStatusRuntimeLabel(target), + label: getCodexStatusRuntimeLabel(target, options.hostLabel), runtimeTarget: target, targets: [ { @@ -510,6 +519,29 @@ function getClaudeStatusAccountsFromSettings( } } +// Why: with a Remote Orca Server active, accounts persisted in local +// GlobalSettings describe this desktop, not the account owner; the snapshot +// fetched from the server must win (#7973). +export function resolveCodexStatusAccountState( + settings: GlobalSettings | null | undefined, + runtimeState: CodexRateLimitAccountsState +): CodexRateLimitAccountsState { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return runtimeState + } + return getCodexStatusAccountsFromSettings(settings) ?? runtimeState +} + +export function resolveClaudeStatusAccountState( + settings: GlobalSettings | null | undefined, + runtimeState: ClaudeRateLimitAccountsState +): ClaudeRateLimitAccountsState { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return runtimeState + } + return getClaudeStatusAccountsFromSettings(settings) ?? runtimeState +} + function CodexRestartStatusPrompt(): React.JSX.Element | null { const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) @@ -648,8 +680,15 @@ function ClaudeSwitcherMenu({ const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts) const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget) const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const providerAccountHostLabel = hasActiveRuntimeEnvironment + ? (runtimeEnvironments.find( + (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() + )?.name ?? + translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server')) + : undefined const windowsTerminalCapabilities = useWindowsTerminalCapabilities( navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment, false, @@ -661,9 +700,9 @@ function ClaudeSwitcherMenu({ if (!settings) { return 'no-settings' } - return `${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) - const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts + const accountState = resolveClaudeStatusAccountState(settings, accounts) useEffect(() => { mountedRef.current = true @@ -672,12 +711,15 @@ function ClaudeSwitcherMenu({ } }, []) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + // Why: keyed on the owner id, not the settings object identity, so routine + // settings mutations don't re-run the remote snapshot round trip. const loadAccounts = useCallback(async () => { - const next = await window.api.claudeAccounts.list() + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) if (mountedRef.current) { - setAccounts(next) + setAccounts(snapshot.claude) } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { void loadAccounts().catch((error) => { @@ -694,13 +736,14 @@ function ClaudeSwitcherMenu({ // Why: inactive-account usage is needed only for the explicit switcher // expansion, so fetch it on that event instead of one render later. + // Remote-owned accounts have no local inactive-usage cache to fill. const handleAccountsExpandedToggle = useCallback((): void => { const nextExpanded = !accountsExpanded setAccountsExpanded(nextExpanded) - if (nextExpanded) { + if (nextExpanded && !hasActiveRuntimeEnvironment) { void fetchInactiveClaudeAccountUsage() } - }, [accountsExpanded, fetchInactiveClaudeAccountUsage]) + }, [accountsExpanded, fetchInactiveClaudeAccountUsage, hasActiveRuntimeEnvironment]) const handleSelectAccount = async ( accountId: string | null, @@ -711,7 +754,7 @@ function ClaudeSwitcherMenu({ } setIsSwitching(true) try { - const next = await window.api.claudeAccounts.select({ + const next = await selectClaudeProviderAccount(settings, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -720,7 +763,11 @@ function ClaudeSwitcherMenu({ if (mountedRef.current) { setAccounts(next) } - await fetchSettings() + // Why: remote selections live on the server; local GlobalSettings + // account fields are untouched, so refetching them is pure churn. + if (!hasActiveRuntimeEnvironment) { + await fetchSettings() + } if (mountedRef.current) { setAccountsExpanded(false) } @@ -760,7 +807,8 @@ function ClaudeSwitcherMenu({ toCodexStatusRuntimeTarget(claudeTarget), { fallbackWslDistro, - includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings), + hostLabel: providerAccountHostLabel } ) const selectedGroup = @@ -1201,8 +1249,15 @@ function CodexSwitcherMenu({ const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts) const codexTarget = useAppStore((s) => s.rateLimits.codexTarget) const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const providerAccountHostLabel = hasActiveRuntimeEnvironment + ? (runtimeEnvironments.find( + (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() + )?.name ?? + translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server')) + : undefined const windowsTerminalCapabilities = useWindowsTerminalCapabilities( navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment, false, @@ -1214,16 +1269,19 @@ function CodexSwitcherMenu({ if (!settings) { return 'no-settings' } - return `${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) - const accountState = getCodexStatusAccountsFromSettings(settings) ?? accounts + const accountState = resolveCodexStatusAccountState(settings, accounts) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + // Why: keyed on the owner id, not the settings object identity, so routine + // settings mutations don't re-run the remote snapshot round trip. const loadAccounts = useCallback(async () => { - const next = await window.api.codexAccounts.list() + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) if (mountedRef.current) { - setAccounts(next) + setAccounts(snapshot.codex) } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { mountedRef.current = true @@ -1256,7 +1314,7 @@ function CodexSwitcherMenu({ const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await window.api.codexAccounts.select({ + const next = await selectCodexProviderAccount(settings, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -1265,7 +1323,11 @@ function CodexSwitcherMenu({ if (mountedRef.current) { setAccounts(next) } - await fetchSettings() + // Why: remote selections live on the server; local GlobalSettings + // account fields are untouched, so refetching them is pure churn. + if (!hasActiveRuntimeEnvironment) { + await fetchSettings() + } const nextActiveAccountId = getCodexStatusActiveId(next, target) if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ @@ -1381,12 +1443,13 @@ function CodexSwitcherMenu({ const handleAccountsExpandedToggle = useCallback((): void => { const nextExpanded = !accountsExpanded setAccountsExpanded(nextExpanded) - if (nextExpanded) { + if (nextExpanded && !hasActiveRuntimeEnvironment) { // Why: inactive-account usage is needed only for the explicit switcher // expansion, so fetch it on that event instead of one render later. + // Remote-owned accounts have no local inactive-usage cache to fill. void fetchInactiveCodexAccountUsage() } - }, [accountsExpanded, fetchInactiveCodexAccountUsage]) + }, [accountsExpanded, fetchInactiveCodexAccountUsage, hasActiveRuntimeEnvironment]) const selectedRuntimeKey = getCodexStatusRuntimeKey( normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) @@ -1400,7 +1463,8 @@ function CodexSwitcherMenu({ toCodexStatusRuntimeTarget(codexTarget), { fallbackWslDistro, - includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings), + hostLabel: providerAccountHostLabel } ) const selectedGroup = @@ -1411,7 +1475,10 @@ function CodexSwitcherMenu({ resetCreditCount !== null ? formatResetCreditExpiry(codex.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null - const canRedeemReset = resetCreditCount !== null && resetCreditCount > 0 + // Why: reset credits redeem against the desktop's own Codex login; with a + // remote account owner the credit does not apply to the server's account. + const canRedeemReset = + !hasActiveRuntimeEnvironment && resetCreditCount !== null && resetCreditCount > 0 return ( a.accountId === target.id) : null + // Why: sign-in spawns a local `codex login`; a remote-owned + // account cannot be re-authenticated from this desktop. const showSignInAction = + !hasActiveRuntimeEnvironment && !target.active && target.id !== null && isUnavailableInactiveUsage(inactiveUsage?.rateLimits) diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index 652b7f7448d..db8b96abaa2 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -7,7 +7,9 @@ import type { import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups, - getStatusBarPreferredWslDistro + getStatusBarPreferredWslDistro, + resolveClaudeStatusAccountState, + resolveCodexStatusAccountState } from './StatusBar' const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' @@ -133,4 +135,97 @@ describe('status bar runtime switch groups', () => { ) ).toBe('Fedora') }) + + it('labels the host account group with the active remote server name', () => { + const state: CodexRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + + expect( + buildCodexStatusSwitchGroups( + state, + { runtime: 'host', wslDistro: null }, + { hostLabel: 'Repro Server' } + )[0]?.label + ).toBe('Repro Server') + }) + + it('prefers the runtime snapshot over local settings accounts when a remote server is active', () => { + const remoteState: CodexRateLimitAccountsState = { + accounts: [ + { + id: 'server-codex-1', + email: 'server@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: 'server-codex-1', + activeAccountIdsByRuntime: { host: 'server-codex-1', wsl: {} } + } + const settings = { + activeRuntimeEnvironmentId: 'env-1', + activeCodexManagedAccountId: 'desktop-codex-1', + activeCodexManagedAccountIdsByRuntime: { host: 'desktop-codex-1', wsl: {} }, + codexManagedAccounts: [ + { + id: 'desktop-codex-1', + email: 'desktop@example.com', + managedHomePath: '/tmp/desktop-codex-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } as GlobalSettings + + expect(resolveCodexStatusAccountState(settings, remoteState)).toBe(remoteState) + // Without a remote owner, settings-derived accounts win as before. + expect( + resolveCodexStatusAccountState( + { ...settings, activeRuntimeEnvironmentId: null }, + remoteState + ).accounts.map((account) => account.id) + ).toEqual(['desktop-codex-1']) + }) + + it('prefers the runtime snapshot for Claude accounts when a remote server is active', () => { + const remoteState: ClaudeRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + const settings = { + activeRuntimeEnvironmentId: 'env-1', + activeClaudeManagedAccountId: 'desktop-claude-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'desktop-claude-1', wsl: {} }, + claudeManagedAccounts: [ + { + id: 'desktop-claude-1', + email: 'desktop@example.com', + managedAuthPath: '/tmp/desktop-claude-1', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } as GlobalSettings + + expect(resolveClaudeStatusAccountState(settings, remoteState)).toBe(remoteState) + expect( + resolveClaudeStatusAccountState( + { ...settings, activeRuntimeEnvironmentId: ' ' }, + remoteState + ).accounts.map((account) => account.id) + ).toEqual(['desktop-claude-1']) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 177a31b2850..7c0e6489fc3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3028,7 +3028,8 @@ "f077f586db": "Don't ask again", "c0e972d726": "Cancel", "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "3bbf140864": "MiniMax Usage", + "remoteServerLabel": "Remote server" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -4789,7 +4790,13 @@ "435df0ee51": "Under Request Headers, copy the Cookie value.", "7492fb3bba": "Paste it here and click Save.", "9fec52de4b": "How to copy the cookie", - "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes." + "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.", + "remoteServerFallback": "the remote server", + "loadAccountsFailed": "Could not load provider accounts.", + "remoteScopeAccounts": "Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.", + "remoteScopeAuthContext": "Each account keeps its own sign-in context on {{value0}}.", + "remoteEmptyClaudeAccounts": "No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.", + "remoteEmptyCodexAccounts": "No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server." }, "AdvancedPane": { "40b29e0bf3": "Restart", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e4c1f062caf..fbb81c2f8a7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3028,7 +3028,8 @@ "f077f586db": "No volver a preguntar", "c0e972d726": "Cancelar", "06741a2f3d": "Abrir detalles de uso de MiniMax", - "3bbf140864": "Uso de MiniMax" + "3bbf140864": "Uso de MiniMax", + "remoteServerLabel": "Servidor remoto" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4789,7 +4790,13 @@ "435df0ee51": "En Encabezados de solicitud, copia el valor del encabezado Cookie.", "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", "9fec52de4b": "Cómo copiar la cookie", - "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso." + "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso.", + "remoteServerFallback": "el servidor remoto", + "loadAccountsFailed": "No se pudieron cargar las cuentas de proveedor.", + "remoteScopeAccounts": "Mostrando las cuentas administradas por {{value0}}. Agrega o vuelve a autenticar cuentas en ese servidor.", + "remoteScopeAuthContext": "Cada cuenta mantiene su propio contexto de inicio de sesión en {{value0}}.", + "remoteEmptyClaudeAccounts": "No hay cuentas de Claude administradas en {{value0}}. Usa su inicio de sesión de Claude predeterminado del sistema; agrega cuentas en ese servidor.", + "remoteEmptyCodexAccounts": "No hay cuentas de Codex administradas en {{value0}}. Usa su inicio de sesión de Codex predeterminado del sistema; agrega cuentas en ese servidor." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 01e1c9d6973..22a6880f9b9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3028,7 +3028,8 @@ "f077f586db": "今後表示しない", "c0e972d726": "キャンセル", "06741a2f3d": "MiniMax の使用量詳細を開く", - "3bbf140864": "MiniMax 使用量" + "3bbf140864": "MiniMax 使用量", + "remoteServerLabel": "リモートサーバー" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -4774,7 +4775,13 @@ "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", "7492fb3bba": "ここに貼り付けて保存をクリックします。", "9fec52de4b": "Cookie のコピー方法", - "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" + "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。", + "remoteServerFallback": "リモートサーバー", + "loadAccountsFailed": "プロバイダーアカウントを読み込めませんでした。", + "remoteScopeAccounts": "{{value0}} が管理するアカウントを表示しています。アカウントの追加や再認証はそのサーバー上で行ってください。", + "remoteScopeAuthContext": "各アカウントは {{value0}} 上に独自のサインインコンテキストを保持します。", + "remoteEmptyClaudeAccounts": "{{value0}} に管理対象の Claude アカウントはありません。システム既定の Claude ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", + "remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。" }, "AdvancedPane": { "40b29e0bf3": "再起動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 203fa2c5789..2761cfe43dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3028,7 +3028,8 @@ "f077f586db": "다시 묻지 않기", "c0e972d726": "취소", "06741a2f3d": "MiniMax 사용량 세부 정보 열기", - "3bbf140864": "MiniMax 사용량" + "3bbf140864": "MiniMax 사용량", + "remoteServerLabel": "원격 서버" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -4774,7 +4775,13 @@ "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", "9fec52de4b": "Cookie 복사 방법", - "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." + "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다.", + "remoteServerFallback": "원격 서버", + "loadAccountsFailed": "프로바이더 계정을 불러오지 못했습니다.", + "remoteScopeAccounts": "{{value0}}에서 관리하는 계정을 표시하고 있습니다. 계정 추가나 재인증은 해당 서버에서 진행하세요.", + "remoteScopeAuthContext": "각 계정은 {{value0}}에 자체 로그인 컨텍스트를 유지합니다.", + "remoteEmptyClaudeAccounts": "{{value0}}에 관리되는 Claude 계정이 없습니다. 해당 서버의 시스템 기본 Claude 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", + "remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cda02d6db46..e5a2f44f962 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3028,7 +3028,8 @@ "f077f586db": "不再询问", "c0e972d726": "取消", "06741a2f3d": "打开 MiniMax 使用量详情", - "3bbf140864": "MiniMax 使用量" + "3bbf140864": "MiniMax 使用量", + "remoteServerLabel": "远程服务器" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -4774,7 +4775,13 @@ "435df0ee51": "在请求头中,复制 Cookie 的值。", "7492fb3bba": "在此粘贴并点击保存。", "9fec52de4b": "如何复制 Cookie", - "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" + "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。", + "remoteServerFallback": "远程服务器", + "loadAccountsFailed": "无法加载提供商账户。", + "remoteScopeAccounts": "正在显示由 {{value0}} 管理的账户。请在该服务器上添加或重新验证账户。", + "remoteScopeAuthContext": "每个账户在 {{value0}} 上保留自己的登录上下文。", + "remoteEmptyClaudeAccounts": "{{value0}} 上没有受管理的 Claude 账户。它使用其系统默认的 Claude 登录;请在该服务器上添加账户。", + "remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts new file mode 100644 index 00000000000..663d052e30a --- /dev/null +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../../shared/types' +import { + fetchProviderAccountsSnapshot, + removeClaudeProviderAccount, + removeCodexProviderAccount, + selectClaudeProviderAccount, + selectCodexProviderAccount, + watchProviderAccounts, + type ProviderAccountsSnapshot +} from './runtime-provider-accounts-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const LOCAL = { activeRuntimeEnvironmentId: null } +const REMOTE = { activeRuntimeEnvironmentId: 'env-1' } + +function emptyClaudeState(): ClaudeRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +function emptyCodexState(): CodexRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +function snapshotFixture(marker: string): ProviderAccountsSnapshot { + return { + claude: { + ...emptyClaudeState(), + activeAccountId: `claude-${marker}` + }, + codex: { + ...emptyCodexState(), + activeAccountId: `codex-${marker}` + }, + rateLimits: null + } +} + +type SubscriptionCallbacks = { + onResponse: (response: unknown) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void +} + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() +const claudeListLocal = vi.fn() +const codexListLocal = vi.fn() +const claudeSelectLocal = vi.fn() +const codexSelectLocal = vi.fn() +const claudeRemoveLocal = vi.fn() +const codexRemoveLocal = vi.fn() +const unsubscribe = vi.fn() + +let subscriptionCallbacks: SubscriptionCallbacks | null = null + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.restoreAllMocks() + for (const mock of [ + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall, + runtimeEnvironmentSubscribe, + claudeListLocal, + codexListLocal, + claudeSelectLocal, + codexSelectLocal, + claudeRemoveLocal, + codexRemoveLocal, + unsubscribe + ]) { + mock.mockReset() + } + subscriptionCallbacks = null + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + runtimeEnvironmentSubscribe.mockImplementation( + async (_args: unknown, callbacks: SubscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe, sendBinary: () => false } + } + ) + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + subscribe: runtimeEnvironmentSubscribe + }, + claudeAccounts: { + list: claudeListLocal, + select: claudeSelectLocal, + remove: claudeRemoveLocal + }, + codexAccounts: { + list: codexListLocal, + select: codexSelectLocal, + remove: codexRemoveLocal + } + } + }) +}) + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('watchProviderAccounts', () => { + it('reads local services once when no runtime environment is active', async () => { + claudeListLocal.mockResolvedValue(emptyClaudeState()) + codexListLocal.mockResolvedValue(emptyCodexState()) + const snapshots: ProviderAccountsSnapshot[] = [] + + watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => { + throw new Error('unexpected error') + } + }) + await flushMicrotasks() + + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.rateLimits).toBeNull() + expect(claudeListLocal).toHaveBeenCalledTimes(1) + expect(codexListLocal).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentSubscribe).not.toHaveBeenCalled() + }) + + it('does not deliver a late local snapshot after close', async () => { + let resolveClaude: (state: ClaudeRateLimitAccountsState) => void = () => {} + claudeListLocal.mockImplementation( + () => new Promise((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockResolvedValue(emptyCodexState()) + const snapshots: ProviderAccountsSnapshot[] = [] + + const watcher = watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => {} + }) + watcher.close() + resolveClaude(emptyClaudeState()) + await flushMicrotasks() + + expect(snapshots).toHaveLength(0) + }) + + it('streams remote snapshots from accounts.subscribe and unsubscribes on close', async () => { + const snapshots: ProviderAccountsSnapshot[] = [] + const watcher = watchProviderAccounts(REMOTE, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => { + throw new Error('unexpected error') + } + }) + await flushMicrotasks() + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'accounts.subscribe' }), + expect.any(Object) + ) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('ready') } + }) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'snapshot', snapshot: snapshotFixture('refresh') } + }) + + expect(snapshots.map((s) => s.codex.activeAccountId)).toEqual(['codex-ready', 'codex-refresh']) + expect(claudeListLocal).not.toHaveBeenCalled() + + watcher.close() + expect(unsubscribe).toHaveBeenCalledTimes(1) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'snapshot', snapshot: snapshotFixture('late') } + }) + expect(snapshots).toHaveLength(2) + }) + + it('surfaces remote subscription failures as errors', async () => { + const errors: unknown[] = [] + watchProviderAccounts(REMOTE, { + onSnapshot: () => { + throw new Error('unexpected snapshot') + }, + onError: (error) => errors.push(error) + }) + await flushMicrotasks() + + subscriptionCallbacks?.onResponse({ + ok: false, + error: { code: 'forbidden', message: 'denied' } + }) + + expect(errors).toHaveLength(1) + expect(String((errors[0] as Error).message)).toContain('denied') + }) +}) + +describe('fetchProviderAccountsSnapshot', () => { + it('resolves with the first remote snapshot and closes the subscription', async () => { + const pending = fetchProviderAccountsSnapshot(REMOTE) + await flushMicrotasks() + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('ready') } + }) + + await expect(pending).resolves.toMatchObject({ + codex: { activeAccountId: 'codex-ready' } + }) + expect(unsubscribe).toHaveBeenCalled() + }) + + it('rejects when the remote subscription closes before any snapshot', async () => { + const pending = fetchProviderAccountsSnapshot(REMOTE) + await flushMicrotasks() + subscriptionCallbacks?.onClose?.() + + await expect(pending).rejects.toThrow('subscription closed') + }) +}) + +describe('provider account mutations', () => { + it('routes select through local IPC with the full runtime target when local', async () => { + codexSelectLocal.mockResolvedValue(emptyCodexState()) + claudeSelectLocal.mockResolvedValue(emptyClaudeState()) + + await selectCodexProviderAccount(LOCAL, { + accountId: 'acc-1', + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + await selectClaudeProviderAccount(LOCAL, { accountId: null, runtime: 'host', wslDistro: null }) + + expect(codexSelectLocal).toHaveBeenCalledWith({ + accountId: 'acc-1', + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(claudeSelectLocal).toHaveBeenCalledWith({ + accountId: null, + runtime: 'host', + wslDistro: null + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes select and remove through the active runtime accounts RPC when remote', async () => { + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => ({ + id: 'call', + ok: true, + result: args.method.startsWith('accounts.select') ? emptyCodexState() : emptyClaudeState() + })) + + await selectCodexProviderAccount(REMOTE, { + accountId: 'server-codex-2', + runtime: 'host', + wslDistro: null + }) + await selectClaudeProviderAccount(REMOTE, { + accountId: null, + runtime: 'host', + wslDistro: null + }) + await removeCodexProviderAccount(REMOTE, 'server-codex-1') + await removeClaudeProviderAccount(REMOTE, 'server-claude-1') + + const methods = runtimeEnvironmentCall.mock.calls.map( + (call) => (call[0] as { method: string; params: unknown }).method + ) + expect(methods).toEqual([ + 'accounts.selectCodex', + 'accounts.selectClaude', + 'accounts.removeCodex', + 'accounts.removeClaude' + ]) + expect(runtimeEnvironmentCall.mock.calls[0]?.[0]).toMatchObject({ + selector: 'env-1', + // Why this matters: the server API takes only accountId; host/WSL + // targeting is a desktop-local concept and must not leak into params. + params: { accountId: 'server-codex-2' } + }) + expect(codexSelectLocal).not.toHaveBeenCalled() + expect(claudeSelectLocal).not.toHaveBeenCalled() + expect(codexRemoveLocal).not.toHaveBeenCalled() + expect(claudeRemoveLocal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts new file mode 100644 index 00000000000..1bb85fdea85 --- /dev/null +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -0,0 +1,227 @@ +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, + GlobalSettings +} from '../../../shared/types' +import type { RateLimitState } from '../../../shared/rate-limit-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' + +// Mirrors OrcaRuntime.getAccountsSnapshot() / the accounts.subscribe payload. +export type ProviderAccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState | null +} + +type ProviderAccountSelection = { + accountId: string | null + runtime: 'host' | 'wsl' + wslDistro?: string | null +} + +type ProviderAccountsSubscriptionMessage = { + type: 'ready' | 'snapshot' | 'end' + snapshot?: ProviderAccountsSnapshot +} + +const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 +// Why: the server applies a selection before it awaits provider usage +// refreshes, and those refreshes can crawl behind broken auth. Give the call +// room to finish instead of reporting failure for an applied switch. +const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 + +export function hasRemoteProviderAccountOwner( + settings: Pick | null | undefined +): boolean { + return getActiveRuntimeTarget(settings).kind === 'environment' +} + +export type ProviderAccountsWatcher = { + close: () => void +} + +// Watches the provider-account snapshot for whichever runtime owns accounts. +// Local: one-shot reads of the desktop services (they have no push channel +// here; the pane refetches after each mutation). Remote: a live +// accounts.subscribe stream — the ready message carries the current snapshot +// synchronously and later broadcasts deliver refreshed usage. accounts.list is +// deliberately avoided: it blocks behind provider usage refreshes on the +// server and can hang for minutes behind broken auth. +export function watchProviderAccounts( + settings: Pick | null | undefined, + handlers: { + onSnapshot: (snapshot: ProviderAccountsSnapshot) => void + onError: (error: unknown) => void + } +): ProviderAccountsWatcher { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + let closed = false + void Promise.all([window.api.claudeAccounts.list(), window.api.codexAccounts.list()]) + .then(([claude, codex]) => { + if (!closed) { + handlers.onSnapshot({ claude, codex, rateLimits: null }) + } + }) + .catch((error: unknown) => { + if (!closed) { + handlers.onError(error) + } + }) + return { + close: () => { + closed = true + } + } + } + + let closed = false + let unsubscribe: (() => void) | null = null + let receivedSnapshot = false + // Why: a subscription that never produces a first snapshot looks identical + // to a loading state; surface it as an error so the pane can say so. + const firstSnapshotTimer = window.setTimeout(() => { + if (!closed && !receivedSnapshot) { + handlers.onError(new Error('Timed out waiting for remote provider accounts.')) + } + }, REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: target.environmentId, + method: 'accounts.subscribe', + timeoutMs: REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS + }, + { + onResponse: (response) => { + if (closed) { + return + } + const typed = response as RuntimeRpcResponse + if (typed.ok === false) { + handlers.onError(new RuntimeRpcCallError(typed)) + return + } + const message = typed.result + if ((message.type === 'ready' || message.type === 'snapshot') && message.snapshot) { + receivedSnapshot = true + handlers.onSnapshot(message.snapshot) + } + }, + onError: (error) => { + if (!closed) { + handlers.onError(new Error(error.message)) + } + }, + onClose: () => { + if (!closed && !receivedSnapshot) { + handlers.onError(new Error('Remote provider account subscription closed.')) + } + } + } + ) + .then((handle) => { + unsubscribe = handle.unsubscribe + if (closed) { + unsubscribe() + } + }) + .catch((error: unknown) => { + if (!closed) { + handlers.onError(error) + } + }) + + return { + close: () => { + closed = true + window.clearTimeout(firstSnapshotTimer) + unsubscribe?.() + } + } +} + +// One-shot convenience over watchProviderAccounts for surfaces that only need +// the current snapshot (status-bar switcher menus). +export async function fetchProviderAccountsSnapshot( + settings: Pick | null | undefined +): Promise { + return await new Promise((resolve, reject) => { + const watcher = watchProviderAccounts(settings, { + onSnapshot: (snapshot) => { + watcher.close() + resolve(snapshot) + }, + onError: (error) => { + watcher.close() + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +export async function selectClaudeProviderAccount( + settings: Pick | null | undefined, + selection: ProviderAccountSelection +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.selectClaude', + { accountId: selection.accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.claudeAccounts.select(selection) +} + +export async function selectCodexProviderAccount( + settings: Pick | null | undefined, + selection: ProviderAccountSelection +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.selectCodex', + { accountId: selection.accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.codexAccounts.select(selection) +} + +export async function removeClaudeProviderAccount( + settings: Pick | null | undefined, + accountId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.removeClaude', + { accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.claudeAccounts.remove({ accountId }) +} + +export async function removeCodexProviderAccount( + settings: Pick | null | undefined, + accountId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.removeCodex', + { accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.codexAccounts.remove({ accountId }) +} From 27f30c254869bb2074b88762c314bdc5b356860a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:37:40 -0700 Subject: [PATCH 19/25] fix(windows): open WSL workspaces in VS Code remote (#7982) --- docs/issue-7649-vscode-wsl-launch.md | 95 +++++++++++++++++ src/main/external-editor-launch.test.ts | 135 +++++++++++++++++++++++- src/main/external-editor-launch.ts | 22 +++- src/main/ipc/shell.test.ts | 17 +++ src/main/win32-utils.test.ts | 21 ++++ 5 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 docs/issue-7649-vscode-wsl-launch.md diff --git a/docs/issue-7649-vscode-wsl-launch.md b/docs/issue-7649-vscode-wsl-launch.md new file mode 100644 index 00000000000..4643672e41e --- /dev/null +++ b/docs/issue-7649-vscode-wsl-launch.md @@ -0,0 +1,95 @@ +# VS Code WSL Workspace Launch + +## Problem + +On Windows, choosing **Open in VS Code** for a workspace stored under a WSL UNC path opens the folder in a Windows VS Code environment instead of a Remote - WSL window ([issue #7649](https://github.com/stablyai/orca/issues/7649)). The renderer passes the workspace path and configured editor command unchanged (`src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx:103-121`), and the main process delegates launch argument construction to `resolveExternalEditorLaunchSpec` (`src/main/ipc/shell.ts:101-110`). The builder currently gives every non-Cursor executable only the original path (`src/main/external-editor-launch.ts:109-116`). + +The deterministic reproduction for `\\wsl.localhost\Ubuntu\home\aliuq\project` produces `code ` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+ `. + +## Root cause + +`resolveExternalEditorLaunchSpec` does not distinguish a VS Code launch targeting a WSL UNC workspace. Orca already has a shared parser for both modern `\\wsl.localhost\...` and legacy `\\wsl$\...` paths (`src/shared/wsl-paths.ts:1-20`), but the editor launcher never uses it. VS Code therefore receives the Windows-visible UNC folder and correctly opens it as a local Windows workspace. + +## Non-goals + +- Change the Open in menu, settings UI, IPC payload, file-manager behavior, or SSH/remote-runtime behavior. +- Infer WSL identity for ordinary drive-letter paths from project runtime settings. +- Rewrite user-defined compound shell commands or add remote flags to Cursor, VSCodium, or arbitrary editors. +- Install or configure the VS Code WSL extension. + +## Design + +1. In the external-editor launch-spec builder, parse the target path with the shared WSL UNC parser when the host platform is Windows. +2. For direct/executable VS Code Stable or Insiders launchers only, reuse the existing normalized launcher-basename check and translate a recognized WSL target into `['--remote', 'wsl+', '']`. The exact allowlist recognizes Stable's `code`, Insiders' `code-insiders`, and the direct `Code - Insiders.exe` basename without matching unrelated `code-*` editors. Matching is case-insensitive and strips every Windows launcher suffix already supported by Orca (`.cmd`, `.exe`, and `.bat`). +3. Keep local paths, non-Windows hosts, non-VS-Code applications, and compound commands on their existing argument paths. The existing main-process spawn and Windows shim handling remain unchanged. +4. Replace the temporary reproduction harness with focused regression cases in `src/main/external-editor-launch.test.ts` covering modern and legacy WSL UNC forms plus unaffected local/custom-editor behavior. + +## Data flow + +- Worktree menu selects VS Code and sends `(workspacePath, 'code')` over existing IPC. +- Main process validates the absolute existing host path. +- Launch-spec builder resolves the VS Code executable. +- On Windows + WSL UNC + VS Code, the builder emits the Remote - WSL authority and Linux-native folder path. +- Existing Windows spawn wrapping launches VS Code with those arguments. + +## Edge cases + +- Modern `\\wsl.localhost\\...` and legacy `\\wsl$\\...` paths both preserve the distro spelling and convert separators to a POSIX path. +- Distro names and Linux folder paths containing spaces remain single arguments because launch-spec construction and Windows shim wrapping preserve argument-array boundaries. +- A WSL distro root maps to `/`. +- Windows drive-letter and ordinary UNC paths stay local. +- macOS/Linux behavior stays unchanged even for strings that resemble WSL UNC paths. +- Cursor retains `--new-window`; other custom editors retain their existing single path argument. +- Compound commands remain user-owned and are not rewritten because inserting flags safely would require parsing arbitrary shell syntax. +- SSH and remote-runtime workspaces remain blocked from local path opening by the existing renderer guard; this change does not alter that boundary. +- If the VS Code WSL extension is unavailable, launch behavior is left to VS Code and Orca retains its existing spawn-success contract. + +## Test plan + +- Unit: demonstrate the pre-fix launch spec lacks `--remote` for a modern WSL UNC path. +- Unit: assert the fixed modern UNC launch is `--remote`, `wsl+Ubuntu`, `/home/...`. +- Unit: assert legacy `\\wsl$` and distro-root paths produce the same Remote - WSL form. +- Unit: assert direct and resolved Windows VS Code Stable and Insiders launchers are matched case-insensitively across `.exe`, `.cmd`, and `.bat` suffixes. +- Unit: assert distro names and Linux folder paths containing spaces remain intact arguments through launch-spec construction and Windows shim forwarding. +- Unit: assert a Windows local path remains unchanged and explicit `darwin` and `linux` hosts do not acquire WSL remote arguments. +- Unit: assert Cursor and another custom editor are not given VS Code remote arguments. +- Integration/Electron: create a throwaway repo in the installed Ubuntu WSL distro, add/open it in Orca, choose Open in VS Code, and verify the VS Code remote indicator and an integrated-terminal Linux probe. +- Adjacent smoke: open a local Windows workspace in VS Code and verify it remains a local Windows window. +- Repository gates: focused Vitest files, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`. + +## UI quality bar + +Not UI-visible in Orca. The existing menu, labels, loading behavior, and errors do not change. The user-visible acceptance criterion is external: the launched VS Code window must identify the selected WSL distro and its terminal must run Linux. + +## Review screenshots + +1. Golden path: VS Code opened from a throwaway WSL workspace, showing the Remote - WSL indicator and a terminal Linux probe. +2. Adjacent local path: VS Code opened from a local Windows workspace, showing a local Windows terminal/environment. + +## Rollout + +1. Add launch-spec regression tests and observe the WSL case fail against the desired arguments. +2. Add the scoped VS Code + Windows + WSL argument translation. +3. Run focused and repository-wide static/test gates. +4. Validate WSL and local launches end to end, retaining local screenshots outside the PR. + +## Lightweight Eng Review + +- Scope: Kept to the launch-spec seam; no renderer, IPC, persistence, runtime-routing, or settings changes are needed because WSL filesystem identity is encoded in the UNC path. +- Architecture/data flow: Reuse `parseWslUncPath` in the main-process pure argument builder, then leave executable resolution, Windows shim wrapping, spawn lifecycle, and renderer guards unchanged. +- Failure modes covered: + - Modern and legacy WSL UNC aliases route to the correct distro and Linux path. + - Local/ordinary UNC paths and non-Windows platforms do not acquire remote flags. + - Case variants and supported Windows VS Code shim suffixes are recognized without classifying custom editors or compound shell commands as VS Code. + - Distro and folder names containing spaces survive the Windows shim as distinct arguments. + - Missing VS Code WSL support still follows the existing launch contract rather than adding a new partial-failure protocol. +- Test coverage required: + - Pure launch-spec regression cases in `src/main/external-editor-launch.test.ts` for WSL aliases/root, launcher case/suffix variants, and unaffected branches. + - `src/main/ipc/shell.test.ts` plus the existing Windows shim contract to prove remote arguments, including spaces, are forwarded as distinct values. + - Live Windows + Ubuntu WSL + VS Code smoke for the actual environment boundary. +- Performance/blast radius: One regex parse per external-editor click only; no startup, polling, watcher, terminal, or renderer cost. Blast radius is limited to direct VS Code launches of WSL UNC paths on Windows. +- UI quality bar: Not UI-visible in Orca; VS Code must visibly attach to the requested WSL distro and run a Linux terminal. +- Required review screenshots: + 1. WSL VS Code window with distro indicator and Linux terminal probe. + 2. Local Windows VS Code window demonstrating unchanged local launch behavior. +- Residual risks: VS Code without the WSL extension may reject or prompt on the valid remote launch; this is external dependency behavior and should not cause Orca to fall back silently to the wrong Windows environment. diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 507e9b5700d..8ced95ffd2f 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveCliCommandMock } = vi.hoisted(() => ({ + resolveCliCommandMock: vi.fn((command: string) => command) +})) + +vi.mock('./codex-cli/command', () => ({ + resolveCliCommand: resolveCliCommandMock +})) + import { getCmdExePath } from './win32-utils' import { resolveExternalEditorLaunchSpec } from './external-editor-launch' describe('resolveExternalEditorLaunchSpec', () => { + beforeEach(() => { + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockImplementation((command: string) => command) + }) + it('keeps simple CLI commands on the executable launch path', () => { const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', { platform: 'darwin' @@ -121,4 +135,123 @@ describe('resolveExternalEditorLaunchSpec', () => { spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca'] }) }) + + it.each(['code', 'code-insiders'])( + 'opens modern WSL UNC workspaces with %s in the matching VS Code remote', + (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\aliuq\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/aliuq/project']) + } + ) + + it.each([ + [ + 'legacy WSL UNC workspace', + '\\\\wsl$\\Debian\\home\\ada\\project', + 'wsl+Debian', + '/home/ada/project' + ], + ['modern WSL distro root', '\\\\wsl.localhost\\Ubuntu', 'wsl+Ubuntu', '/'], + ['legacy WSL distro root', '\\\\wsl$\\Debian', 'wsl+Debian', '/'] + ])('opens a %s in the matching VS Code remote', (_label, pathValue, authority, linuxPath) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual(['--remote', authority, linuxPath]) + }) + + it.each([ + 'C:\\Program Files\\Microsoft VS Code\\Code.exe', + 'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe', + 'C:\\Tools\\CODE.CMD', + 'C:\\Tools\\code.bat', + 'C:\\Tools\\code-insiders.cmd' + ])('recognizes the direct Windows VS Code launcher %s', (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/ada/project']) + }) + + it.each([ + ['code', 'C:\\Tools\\CODE.CMD'], + ['code', 'C:\\Tools\\code.bat'], + ['code-insiders', 'C:\\Tools\\code-insiders.cmd'] + ])('recognizes the resolved Windows VS Code launcher %s', (editorCommand, resolvedCommand) => { + resolveCliCommandMock.mockReturnValueOnce(resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs: ['--remote', 'wsl+Ubuntu', '/home/ada/project'] + }) + }) + + it('preserves spaces in WSL distro and folder arguments', () => { + expect( + resolveExternalEditorLaunchSpec( + 'code', + '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project']) + }) + + it.each(['darwin', 'linux'] as const)('keeps WSL-looking paths local on %s', (platform) => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + expect(resolveExternalEditorLaunchSpec('code', pathValue, { platform }).spawnArgs).toEqual([ + pathValue + ]) + }) + + it.each(['C:\\workspaces\\orca', '\\\\server\\share\\project'])( + 'keeps the non-WSL Windows path %s local', + (pathValue) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual([pathValue]) + } + ) + + it('does not add VS Code remote arguments to other editors', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual(['--new-window', pathValue]) + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual([pathValue]) + }) + + it('does not rewrite compound VS Code commands', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', pathValue, { platform: 'win32' }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`] + }) + }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 8e5c4a0b397..b86ca3e896f 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,10 +1,12 @@ import { existsSync } from 'node:fs' import { basename, posix, win32 } from 'node:path' +import { parseWslUncPath } from '../shared/wsl-paths' import { resolveCliCommand } from './codex-cli/command' import { getCmdExePath } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +const VSCODE_REMOTE_EDITORS = new Set(['code', 'code-insiders', 'code - insiders']) export type ExternalEditorLaunchSpec = | { @@ -106,12 +108,24 @@ function shouldShowWindowsConsole( return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options)) } -function buildExecutableArgs(editorCommand: string, pathValue: string): string[] { - if (getLauncherBaseName(editorCommand) === 'cursor') { +function buildExecutableArgs( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): string[] { + const launcherBaseName = getLauncherBaseName(editorCommand) + if (launcherBaseName === 'cursor') { // Why: Cursor can route bare folder launches through the last active // workbench. A new window keeps "Open in Cursor" scoped to this worktree. return ['--new-window', pathValue] } + if (platform === 'win32' && VSCODE_REMOTE_EDITORS.has(launcherBaseName)) { + const wslPath = parseWslUncPath(pathValue) + if (wslPath) { + // Why: VS Code otherwise treats a WSL UNC path as a local Windows folder. + return ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] + } + } return [pathValue] } @@ -156,7 +170,7 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } @@ -169,6 +183,6 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 2e1b842904d..b3afc70b4a5 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -321,6 +321,23 @@ describe('registerShellHandlers', () => { ]) }) + it.runIf(process.platform === 'win32')( + 'forwards WSL remote arguments with spaces through the Windows launcher shim', + async () => { + const workspacePath = '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project' + const codeShim = 'C:\\Tools\\CODE.CMD' + resolveCliCommandMock.mockReturnValueOnce(codeShim) + const handler = getHandler('shell:openInExternalEditor') + + await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true }) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + } + ) + it('shows the Windows console for NeoVim executable launchers on Windows', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index d291198a0e0..0a6d381d3c0 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + getCmdExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -65,6 +66,26 @@ describe('getSpawnArgsForWindows', () => { } }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'C:\\tools\\code.cmd', + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + }) + }) + it('passes .exe through unchanged on win32', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.exe', ['login']) From f55d75372142c6bba7a770601e6b3da7b4b6bb80 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:40:11 -0700 Subject: [PATCH 20/25] fix(codex): use WSL login shell for account setup (#7993) --- src/main/codex-accounts/service.test.ts | 39 ++++++----------- src/main/codex-accounts/service.ts | 31 +++++-------- .../codex-accounts/wsl-codex-command.test.ts | 23 ++++++++++ src/main/codex-accounts/wsl-codex-command.ts | 43 +++++++++++++++++++ 4 files changed, 89 insertions(+), 47 deletions(-) create mode 100644 src/main/codex-accounts/wsl-codex-command.test.ts create mode 100644 src/main/codex-accounts/wsl-codex-command.ts diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 69fed98ea8d..abcafe1b438 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' const testState = { userDataDir: '', @@ -766,20 +767,20 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } + if (script.includes('command -v codex')) { + throw new Error('bash -ic does not inherit the distro login-shell PATH') + } + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) + return '' + } mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') return '' }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Debian', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath)) // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( @@ -876,8 +877,8 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } - if (script.includes('command -v codex')) { - expect(args.slice(0, 5)).toEqual(['-d', 'Debian', '--exec', 'bash', '-ic']) + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) throw new Error('codex missing') } mkdirSync(wslManagedHomePath, { recursive: true }) @@ -960,14 +961,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough stderr: PassThrough @@ -1075,14 +1069,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe( 'account-1\n' ) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index c979725633b..2a0fb54bdc4 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -22,6 +22,11 @@ import type { RateLimitService } from '../rate-limits/service' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + buildWslCodexAvailabilityArgs, + buildWslCodexLoginArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from './wsl-codex-command' import { getCodexSelectionTargetForAccount, getSelectedCodexAccountIdForTarget, @@ -805,15 +810,7 @@ export class CodexAccountService { const spawnConfig = wslInfo ? { command: 'wsl.exe', - // Why: nvm and similar WSL installs often initialize PATH from interactive shell config. - args: [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` - ], + args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, codexCommand: 'codex' } @@ -923,18 +920,10 @@ export class CodexAccountService { private assertWslCodexCliAvailable(wslInfo: { distro: string; linuxPath: string }): void { try { - execFileSync( - 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - buildEncodedWslBashCommand('command -v codex >/dev/null 2>&1') - ], - { encoding: 'utf-8', timeout: 5000 } - ) + execFileSync('wsl.exe', buildWslCodexAvailabilityArgs(wslInfo.distro), { + encoding: 'utf-8', + timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) } catch (error) { throw new Error( `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts new file mode 100644 index 00000000000..f696ddcde4f --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' + +describe('WSL Codex commands', () => { + it('checks the alias-neutral PATH from the distro login shell', () => { + const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev') + + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c']) + expect(args.at(-1)).toContain('getent passwd') + expect(args.at(-1)).toContain('_orca_lookup_command=') + expect(args.at(-1)).toContain('codex') + expect(args.at(-1)).not.toContain('bash -ic') + }) + + it('launches the resolved Codex executable with its quoted managed home', () => { + const args = buildWslCodexLoginArgs('Ubuntu', '/home/alice/managed-home') + const command = args.at(-1) + + expect(command).toContain('export CODEX_HOME=') + expect(command).toContain('/home/alice/managed-home') + expect(command).toContain('exec "\\$resolved" login') + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts new file mode 100644 index 00000000000..02527c8ed23 --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -0,0 +1,43 @@ +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' + +export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000 + +export function buildWslCodexAvailabilityArgs(distro: string): string[] { + const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" login' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +function buildCodexPathLookup(): string { + return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) +} + +function buildWslCodexShellArgs(distro: string, command: string): string[] { + // Why: Codex must use the distro user's configured login shell, whose PATH + // can differ from a hard-coded non-login bash invocation. + return [ + '-d', + distro, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ] +} From 5662bf49d82e10b44234c9e7e447cf04650b04d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:06:47 -0700 Subject: [PATCH 21/25] Restore Linear agent-skill setup prompt and add provider-card install CTA (#7990) PR #5071 (36277801e) accidentally dropped the LinearAgentSkillSetupPrompt modal from WorktreeCard, orphaning the component. Restore the exact wiring: render on the active worktree when it has a linked Linear issue. Also surface the decoupled orca-linear agent skill on the Linear task provider settings card: install state via useInstalledAgentSkillNames, copyable install/update command resolved for the agent runtime, and a remote-setup note when a runtime environment is active. The legacy-aware update-command selection moves into a shared lib module so the sidebar prompt and the new CTA stay in sync. Co-authored-by: Brennan Benson --- ...nt-skill-installed-command-callers.test.ts | 4 +- .../linear-agent-skill-install-cta.test.tsx | 185 +++++++++++++++++ .../linear-agent-skill-install-cta.tsx | 186 ++++++++++++++++++ .../task-tracker-integration-cards.test.tsx | 21 +- .../task-tracker-integration-cards.tsx | 2 + .../sidebar/LinearAgentSkillSetupPrompt.tsx | 28 +-- .../src/components/sidebar/WorktreeCard.tsx | 10 + src/renderer/src/i18n/locales/en.json | 20 ++ src/renderer/src/i18n/locales/es.json | 20 ++ src/renderer/src/i18n/locales/ja.json | 20 ++ src/renderer/src/i18n/locales/ko.json | 20 ++ src/renderer/src/i18n/locales/zh.json | 20 ++ .../lib/linear-agent-skill-update-command.ts | 28 +++ 13 files changed, 541 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx create mode 100644 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx create mode 100644 src/renderer/src/lib/linear-agent-skill-update-command.ts diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 5b7b8e020cd..8992e73499a 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -44,8 +44,10 @@ const updateCapableCallers = new Map([ ['ORCA_CLI_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}'] ], [ + // Why: the single-skill update command selection moved into + // getLinearAgentSkillUpdateCommand so the settings install CTA shares it. 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx', - ['ORCA_LINEAR_SKILL_UPDATE_COMMAND', 'installedCommand={installedCommand}'] + ['getLinearAgentSkillUpdateCommand', 'installedCommand={installedCommand}'] ], [ 'src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx', diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx new file mode 100644 index 00000000000..a3a4a1db3dd --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' + +const mocks = vi.hoisted(() => ({ + skillState: { + installed: false, + loading: false, + error: null as string | null, + skills: [] as DiscoveredSkill[], + refresh: vi.fn(async () => true) + }, + useInstalledAgentSkillNames: vi.fn(), + clipboardWrite: vi.fn(async () => {}), + toastSuccess: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/hooks/useInstalledAgentSkills', async (importOriginal) => ({ + ...(await importOriginal()), + useInstalledAgentSkillNames: mocks.useInstalledAgentSkillNames +})) + +vi.mock('./CliSkillRuntimeSetup', () => ({ + buildSkillCommandForRuntime: (command: string) => command +})) + +vi.mock('sonner', () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError } +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function discoveredSkill(overrides: Partial): DiscoveredSkill { + return { + id: 'skill-1', + name: 'orca-linear', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/Users/test/.agents/skills', + directoryPath: '/Users/test/.agents/skills/orca-linear', + skillFilePath: '/Users/test/.agents/skills/orca-linear/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null, + ...overrides + } +} + +async function renderCta( + props: Partial> = {} +): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + + + ) + }) + if (!container) { + throw new Error('Container was not created') + } + return container +} + +describe('LinearAgentSkillInstallCta', () => { + beforeEach(() => { + mocks.skillState.installed = false + mocks.skillState.loading = false + mocks.skillState.error = null + mocks.skillState.skills = [] + mocks.skillState.refresh.mockClear() + mocks.useInstalledAgentSkillNames.mockReset() + mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState) + mocks.clipboardWrite.mockClear() + mocks.toastSuccess.mockClear() + mocks.toastError.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { writeClipboardText: mocks.clipboardWrite } } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('shows the install command and explanation when the skill is missing', async () => { + const rendered = await renderCta() + + expect(rendered.textContent).toContain('Agent skill:') + expect(rendered.textContent).toContain('orca-linear') + expect(rendered.textContent).toContain('Not installed') + expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.') + expect(rendered.textContent).toContain( + 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' + ) + }) + + it('copies the install command to the clipboard', async () => { + const rendered = await renderCta() + + await act(async () => { + rendered + .querySelector('button[aria-label="Copy command"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.clipboardWrite).toHaveBeenCalledWith( + 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' + ) + expect(mocks.toastSuccess).toHaveBeenCalled() + }) + + it('shows a subtle confirmation and the update command when installed', async () => { + mocks.skillState.installed = true + mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })] + + const rendered = await renderCta() + + expect(rendered.textContent).toContain('Installed') + expect(rendered.textContent).toContain('Agent skill installed. To update it, run:') + expect(rendered.textContent).toContain('npx skills update orca-linear --global') + expect(rendered.textContent).not.toContain('Not installed') + }) + + it('updates through the legacy skill name when only linear-tickets is installed', async () => { + mocks.skillState.installed = true + mocks.skillState.skills = [ + discoveredSkill({ + name: 'linear-tickets', + directoryPath: '/Users/test/.agents/skills/linear-tickets', + skillFilePath: '/Users/test/.agents/skills/linear-tickets/SKILL.md' + }) + ] + + const rendered = await renderCta() + + expect(rendered.textContent).toContain('npx skills update linear-tickets --global') + }) + + it('notes that remote agent environments need their own setup', async () => { + const rendered = await renderCta({ + settings: { activeRuntimeEnvironmentId: 'runtime-1', terminalWindowsShell: '' } + }) + + expect(rendered.textContent).toContain( + 'This installs host setup; remote agent environments may need separate setup.' + ) + }) + + it('re-checks the skill scan on demand', async () => { + const rendered = await renderCta() + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Re-check')) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.skillState.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx new file mode 100644 index 00000000000..43e755dcbe2 --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -0,0 +1,186 @@ +import { useMemo } from 'react' +import { Copy, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { + LINEAR_AGENT_SKILL_NAMES, + ORCA_LINEAR_SKILL_INSTALL_COMMAND, + ORCA_LINEAR_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command' +import { cn } from '@/lib/utils' +import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy' +import { + getCurrentPlatform, + getLinearPromptAgentRuntime, + getLinearPromptSkillDiscoveryTarget, + type LinearAgentSkillPromptSettings +} from '../sidebar/linear-agent-skill-runtime' +import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup' +import { + useIntegrationCommandRowClass, + useIntegrationSubordinateRowClass +} from './integration-card-presentation' +import { translate } from '@/i18n/i18n' + +type LinearAgentSkillInstallCtaProps = { + settings: LinearAgentSkillPromptSettings | null | undefined +} + +// Why: the Linear task provider and the orca-linear agent skill are decoupled +// setups; this section bridges them so connecting the provider also surfaces +// the skill agents need to read and edit Linear tasks. +export function LinearAgentSkillInstallCta({ + settings +}: LinearAgentSkillInstallCtaProps): React.JSX.Element { + // Why: mirror the sidebar setup prompt's runtime resolution so both surfaces + // agree on which machine (host vs. WSL distro) is scanned and installed to. + const remote = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const agentRuntime = useMemo( + () => getLinearPromptAgentRuntime(settings, getCurrentPlatform(), remote), + [remote, settings] + ) + const skillDiscoveryTarget = useMemo( + () => getLinearPromptSkillDiscoveryTarget(agentRuntime), + [agentRuntime] + ) + const skill = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: skillDiscoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + const command = useMemo( + () => + buildSkillCommandForRuntime( + skill.installed + ? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed) + : ORCA_LINEAR_SKILL_INSTALL_COMMAND, + agentRuntime + ), + [agentRuntime, skill.installed, skill.skills] + ) + const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5') + const commandRowClass = useIntegrationCommandRowClass() + + const copyCommand = async (): Promise => { + try { + await window.api.ui.writeClipboardText(command) + toast.success( + translate( + 'auto.components.settings.linear.agent.skill.install.cta.copiedCommand', + 'Copied command.' + ) + ) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.linear.agent.skill.install.cta.copyFailed', + 'Failed to copy command.' + ) + ) + } + } + + return ( +
+
+

+ {translate( + 'auto.components.settings.linear.agent.skill.install.cta.skillLabel', + 'Agent skill:' + )}{' '} + {ORCA_LINEAR_SKILL_NAME} +

+ {skill.loading ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.checking', + 'Checking...' + )} + + ) : skill.installed ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.installed', + 'Installed' + )} + + ) : ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.notInstalled', + 'Not installed' + )} + + )} + +
+ {skill.error ?

{skill.error}

: null} + {!skill.loading && ( + <> +

+ {skill.installed + ? translate( + 'auto.components.settings.linear.agent.skill.install.cta.installedDescription', + 'Agent skill installed. To update it, run:' + ) + : translate( + 'auto.components.settings.linear.agent.skill.install.cta.description', + 'Let your agents read and edit Linear tasks.' + )} +

+
+ + {command} + + + + + + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.copyCommand', + 'Copy command' + )} + + +
+ {remote || agentRuntime.runtime === 'wsl' ? ( +

+ {getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)} +

+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 0dd26191914..9fce3fc84fc 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -2,9 +2,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getExecutionHostLabel } from '../../../../shared/execution-host' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { TooltipProvider } from '../ui/tooltip' import { LinearIntegrationCard } from './task-tracker-integration-cards' const LOCAL_HOST_LABEL = getExecutionHostLabel('local') @@ -86,12 +87,27 @@ async function renderCard(): Promise { document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render() + root?.render( + + + + ) }) return container } describe('LinearIntegrationCard account scope', () => { + beforeEach(() => { + // Why: the embedded agent-skill CTA scans installed skills through + // window.api; without a stub it renders a scan error instead of a state. + Object.defineProperty(window, 'api', { + configurable: true, + value: { + skills: { discover: async () => ({ skills: [], sources: [], scannedAt: 0 }) } + } + }) + }) + afterEach(async () => { if (root) { await act(async () => { @@ -102,6 +118,7 @@ describe('LinearIntegrationCard account scope', () => { container?.remove() container = null mocks.store.current = null + Reflect.deleteProperty(window, 'api') }) it('shows local-client account ownership when Linear is disconnected', async () => { diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index aca7be03f84..6c2cc1b9575 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { useIntegrationSubordinateRowClass } from './integration-card-presentation' +import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' @@ -200,6 +201,7 @@ export function LinearIntegrationCard(): React.JSX.Element { ) : null} + buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime), [agentRuntime] ) - const canonicalSkillInstalled = hasInstalledAgentSkill(skill.skills, ORCA_LINEAR_SKILL_NAME, { - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) - const legacySkillInstalled = hasInstalledAgentSkill(skill.skills, LINEAR_TICKETS_SKILL_NAME, { - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) - // Why: legacy-only installs must update the installed legacy skill, while - // fresh/canonical/both-name states should move through the canonical name. - const updateCommand = - !skill.installed || canonicalSkillInstalled || !legacySkillInstalled - ? ORCA_LINEAR_SKILL_UPDATE_COMMAND - : LINEAR_TICKETS_SKILL_UPDATE_COMMAND const installedCommand = useMemo( - () => buildSkillCommandForRuntime(updateCommand, agentRuntime), - [agentRuntime, updateCommand] + () => + buildSkillCommandForRuntime( + getLinearAgentSkillUpdateCommand(skill.skills, skill.installed), + agentRuntime + ), + [agentRuntime, skill.installed, skill.skills] ) const terminalShellOverride = getLinearPromptTerminalShellOverride( currentPlatform, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e0692a7f3bc..20cf2861051 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -23,6 +23,7 @@ import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer' import WorktreeContextMenu from './WorktreeContextMenu' import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' +import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' @@ -1727,6 +1728,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
)} + {isActive && worktree.linkedLinearIssue ? ( + + ) : null} + {/* Why: inline agent list. Gated on the 'inline-agents' card property so users can hide it. Layout coupling: this block grows the card height dynamically — WorktreeList uses diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c0e6489fc3..5cca93854e2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8903,6 +8903,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fbb81c2f8a7..f7198149721 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8903,6 +8903,26 @@ "reviewFirst": "Revisar primero", "singular": "Guardar a nivel global no cambiará 1 repositorio con su propia receta.", "tooltipTitle": "Anulaciones de repositorio" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 22a6880f9b9..cb50770b696 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8903,6 +8903,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2761cfe43dd..fc9915e9b2d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8903,6 +8903,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e5a2f44f962..55c9d06a62f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8903,6 +8903,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/lib/linear-agent-skill-update-command.ts b/src/renderer/src/lib/linear-agent-skill-update-command.ts new file mode 100644 index 00000000000..69dbcce84d7 --- /dev/null +++ b/src/renderer/src/lib/linear-agent-skill-update-command.ts @@ -0,0 +1,28 @@ +import type { DiscoveredSkill } from '../../../shared/skills' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + hasInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' +import { + LINEAR_TICKETS_SKILL_NAME, + LINEAR_TICKETS_SKILL_UPDATE_COMMAND, + ORCA_LINEAR_SKILL_NAME, + ORCA_LINEAR_SKILL_UPDATE_COMMAND +} from '@/lib/agent-feature-install-commands' + +// Why: legacy-only installs must update the installed legacy skill, while +// fresh/canonical/both-name states should move through the canonical name. +export function getLinearAgentSkillUpdateCommand( + skills: readonly DiscoveredSkill[], + installed: boolean +): string { + const canonicalSkillInstalled = hasInstalledAgentSkill(skills, ORCA_LINEAR_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + const legacySkillInstalled = hasInstalledAgentSkill(skills, LINEAR_TICKETS_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + return !installed || canonicalSkillInstalled || !legacySkillInstalled + ? ORCA_LINEAR_SKILL_UPDATE_COMMAND + : LINEAR_TICKETS_SKILL_UPDATE_COMMAND +} From 3a7eb8f3120f40220245b0f153d7b7870526e149 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:38:17 -0700 Subject: [PATCH 22/25] fix(mobile): drop smart-dash write-back recovery that kills iOS dictation (#8008) The longer-hyphen recovery path (#5222) reconstructed runs by writing a value that differed from the native field text. After #7933 stores raw field text and normalizes only on send/PTY, that recovery is unreachable and any write-back would reintroduce dictation kill. Map each smart dash to exactly "--" with a single-arg normalizer. --- .../terminal-text-input-normalization.test.ts | 6 ------ .../terminal-text-input-normalization.ts | 19 +++++-------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts index 29e15ea321b..c5213013e31 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.test.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts @@ -11,10 +11,4 @@ describe('normalizeTerminalTextInput', () => { it('keeps ASCII hyphens unchanged', () => { expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file') }) - - it('preserves longer trailing hyphen runs when iOS re-collapses the controlled value', () => { - expect(normalizeTerminalTextInput('—', '--')).toBe('---') - expect(normalizeTerminalTextInput('—', '---')).toBe('----') - expect(normalizeTerminalTextInput('git checkout —', 'git checkout --')).toBe('git checkout ---') - }) }) diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts index 70500421b3b..6316ce1bbaa 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.ts @@ -1,18 +1,9 @@ // Why: iOS smart punctuation can rewrite two ASCII hyphens into a single -// Unicode dash before React Native delivers terminal text input. +// Unicode dash before React Native delivers terminal text input. Each dash maps +// to exactly "--": recovering longer runs (#5222) needed the previous controlled +// value written back into the field, and that write-back kills iOS dictation (#7925). const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g -const IOS_SMART_DASH_REPLACEMENT_TEST = /[\u2013\u2014]/ -export function normalizeTerminalTextInput(text: string, previousText = ''): string { - const normalizedText = text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') - const previousTrailingHyphens = /-+$/.exec(previousText)?.[0] ?? '' - const previousPrefix = previousText.slice(0, previousText.length - previousTrailingHyphens.length) - const collapsedPreviousHyphenRun = - previousTrailingHyphens.length >= 2 && - IOS_SMART_DASH_REPLACEMENT_TEST.test(text) && - (text === `${previousPrefix}\u2013` || text === `${previousPrefix}\u2014`) - if (collapsedPreviousHyphenRun) { - return `${previousText}-` - } - return normalizedText +export function normalizeTerminalTextInput(text: string): string { + return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') } From feac6a5104e0d835221e7e4094149618fe9dd308 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:06:00 -0700 Subject: [PATCH 23/25] Prevent continuous git status scanning in large repositories (#8005) * Prevent continuous git status scanning in large repositories On repos where `git status --untracked-files=all` takes tens of seconds, the background status poll restarted a fresh scan 3s after the previous one finished, keeping a git process at high CPU almost continuously while the workspace sat idle (#7983). The coalesced poll runner now paces reruns by the previous run's duration, split by trigger class: - Evidence-free timer ticks wait 5x the last refresh duration (capped at 5 minutes), bounding idle polling to ~1/6 duty cycle. - Change signals (file-watch events, repo metadata pushes, finished terminal commands, window reveal after hidden) wait only 1x, so real changes in a slow repo still surface promptly; a change signal can pull an already-scheduled tick run earlier, and the strongest pending trigger wins for trailing reruns. - Backoff-deferred scans are skipped while the window is hidden; the becoming-visible run catches up on the short lane. Fast repos keep the exact 3s cadence (the multiplier never drops the gap below the existing floor), and user-triggered refreshes are unaffected (they bypass the poll runner). The stale-conflict poll gets the same pacing, which also spaces slow remote SSH probe chains. Fixes #7983 * Skip hidden-window stale-conflict probes like the status poll --------- Co-authored-by: Brennan Benson --- .../coalesced-poll-runner.test.ts | 185 +++++++++++++++++- .../right-sidebar/coalesced-poll-runner.ts | 97 ++++++--- .../right-sidebar/useGitStatusPolling.ts | 53 ++++- .../lib/window-visibility-interval.test.ts | 45 +++++ .../src/lib/window-visibility-interval.ts | 6 +- 5 files changed, 353 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts index 9f19c4f5813..bff4e78bbdc 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createCoalescedPollRunner } from './coalesced-poll-runner' +import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' function deferred(): { promise: Promise @@ -88,6 +88,189 @@ describe('createCoalescedPollRunner', () => { vi.useRealTimers() }) + describe('slowTaskBackoff', () => { + const BACKOFF = { idleMultiplier: 5, changeSignalMultiplier: 1, maxIntervalMs: 300_000 } + + function makeSlowRunner(): { + runner: CoalescedPollRunner + task: ReturnType + calls: ReturnType[] + } { + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const runner = createCoalescedPollRunner(task, { + minIntervalMs: 3000, + slowTaskBackoff: BACKOFF + }) + return { runner, task, calls } + } + + async function completeRunOfDuration( + runner: CoalescedPollRunner, + calls: ReturnType[], + durationMs: number + ): Promise { + runner.run() + await vi.advanceTimersByTimeAsync(durationMs) + calls.at(-1)?.resolve() + await flushMicrotasks() + } + + it('scales the tick idle gap with the previous run duration', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + // A 35s run (large-monorepo git status) with poll ticks arriving during it. + runner.run() + runner.run() + await vi.advanceTimersByTimeAsync(35_000) + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Ticks during the backoff window must not start a run early. + await vi.advanceTimersByTimeAsync(3000) + runner.run() + await vi.advanceTimersByTimeAsync(171_998) + runner.run() + expect(task).toHaveBeenCalledTimes(1) + + // 5x the 35s duration → next tick-driven run only after 175s idle. + await vi.advanceTimersByTimeAsync(2) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('caps the backoff at maxIntervalMs', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 120_000) + expect(task).toHaveBeenCalledTimes(1) + + // 5 x 120s = 600s uncapped; the cap holds the gap to 300s. + runner.run() + await vi.advanceTimersByTimeAsync(299_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('keeps minIntervalMs as the gap for fast tasks', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 100) + expect(task).toHaveBeenCalledTimes(1) + + // max(3000, 5 x 100ms) = 3000 → normal-repo cadence is unchanged. + runner.run() + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('lets change signals run after the short backoff instead of the tick backoff', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + expect(task).toHaveBeenCalledTimes(1) + + // A change signal waits 1 x 35s, not 5 x 35s. + runner.run({ changeSignal: true }) + await vi.advanceTimersByTimeAsync(34_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('pulls an already-scheduled tick run earlier when a change signal arrives', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + runner.run() // tick scheduled for +175s + await vi.advanceTimersByTimeAsync(10_000) + + runner.run({ changeSignal: true }) // reschedules for end + 35s + await vi.advanceTimersByTimeAsync(24_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // The superseded tick timer must not fire an extra run later. + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(600_000) + expect(task).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('does not let a later tick delay a scheduled change-signal run', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + runner.run({ changeSignal: true }) // scheduled for +35s + await vi.advanceTimersByTimeAsync(10_000) + runner.run() // tick must not push the schedule to +175s + + await vi.advanceTimersByTimeAsync(24_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('keeps change-signal pacing for a signal that arrived mid-run', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + runner.run() + await vi.advanceTimersByTimeAsync(20_000) + runner.run({ changeSignal: true }) // during flight + await vi.advanceTimersByTimeAsync(15_000) + runner.run() // a later tick must not downgrade the pending signal + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Trailing rerun waits 1 x 35s (signal), not 5 x 35s (tick). + await vi.advanceTimersByTimeAsync(34_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + }) + it('cancels scheduled deferred run on dispose', async () => { vi.useFakeTimers() const calls: ReturnType[] = [] diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts index b18eb634fe6..1040ed279e2 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -1,42 +1,95 @@ +export type CoalescedPollRunnerTrigger = { + // True when the caller has evidence something changed (file-watch event, + // repo metadata push signal, finished terminal command) rather than a bare + // timer tick. Change signals wait out a shorter slow-task backoff. + changeSignal?: boolean +} + export type CoalescedPollRunner = { - run: () => void + run: (trigger?: CoalescedPollRunnerTrigger) => void dispose: () => void } +export type SlowTaskBackoffOptions = { + // Idle gap before an evidence-free timer tick may re-run, as a multiple of + // the previous run's duration. + idleMultiplier: number + // Idle gap before a change-signal run may re-run. Kept low so slow repos + // still refresh promptly after real changes, while sustained churn can + // never keep the task running back-to-back. + changeSignalMultiplier: number + maxIntervalMs: number +} + +// Why: on large monorepos a poll task (git status) can take tens of seconds, +// so a fixed idle gap makes polling effectively continuous (#7983). Scale the +// gap with the previous run's duration — aggressively for evidence-free timer +// ticks, mildly for change signals — capped so results never go minutes-stale. export function createCoalescedPollRunner( task: () => Promise, - options?: { minIntervalMs?: number } + options?: { + minIntervalMs?: number + slowTaskBackoff?: SlowTaskBackoffOptions + } ): CoalescedPollRunner { let disposed = false let inFlight = false - let rerun = false + let rerunTrigger: CoalescedPollRunnerTrigger | null = null let lastRunEndedAt = -Infinity + let lastRunDurationMs = 0 let timeoutId: ReturnType | null = null + let timeoutFiresAt = Infinity + const minIntervalMs = options?.minIntervalMs ?? 0 - const run = (): void => { + const requiredIdleMsFor = (trigger?: CoalescedPollRunnerTrigger): number => { + const backoff = options?.slowTaskBackoff + if (!backoff) { + return minIntervalMs + } + const multiplier = trigger?.changeSignal + ? backoff.changeSignalMultiplier + : backoff.idleMultiplier + return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs)) + } + + const clearScheduledRun = (): void => { + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + timeoutFiresAt = Infinity + } + } + + const run = (trigger?: CoalescedPollRunnerTrigger): void => { if (disposed) { return } if (inFlight) { - rerun = true - return - } - if (timeoutId !== null) { + // Why: keep the strongest pending trigger so a change signal arriving + // mid-run is not downgraded to tick pacing by a later timer tick. + rerunTrigger = { changeSignal: rerunTrigger?.changeSignal || trigger?.changeSignal } return } const now = Date.now() - const timeSinceLastEnd = now - lastRunEndedAt - const minInterval = options?.minIntervalMs ?? 0 - if (timeSinceLastEnd < minInterval) { - const delay = minInterval - timeSinceLastEnd + const allowedAt = lastRunEndedAt + requiredIdleMsFor(trigger) + if (now < allowedAt) { + // Why: a change signal may pull an already-scheduled evidence-free run + // earlier; a weaker trigger must never push a scheduled run later. + if (allowedAt >= timeoutFiresAt) { + return + } + clearScheduledRun() + timeoutFiresAt = allowedAt timeoutId = setTimeout(() => { timeoutId = null - run() - }, delay) + timeoutFiresAt = Infinity + run(trigger) + }, allowedAt - now) return } + clearScheduledRun() inFlight = true void task() .catch(() => { @@ -46,10 +99,11 @@ export function createCoalescedPollRunner( .finally(() => { inFlight = false lastRunEndedAt = Date.now() - const shouldRerun = rerun && !disposed - rerun = false - if (shouldRerun) { - run() + lastRunDurationMs = lastRunEndedAt - now + const trailingTrigger = disposed ? null : rerunTrigger + rerunTrigger = null + if (trailingTrigger) { + run(trailingTrigger) } }) } @@ -58,11 +112,8 @@ export function createCoalescedPollRunner( run, dispose: () => { disposed = true - rerun = false - if (timeoutId !== null) { - clearTimeout(timeoutId) - timeoutId = null - } + rerunTrigger = null + clearScheduledRun() } } } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 895b69012a1..b2726dd4380 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -7,7 +7,7 @@ import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' -import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import { hasInteractiveActiveGitStatusConsumer, shouldPollActiveGitStatus @@ -22,6 +22,16 @@ const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS // metadata watch, shell command completion) cover branch switches; the // terminal-only poll is a last-resort backstop for shells without either. const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 +// Why: on a large monorepo one status refresh can take tens of seconds, so the +// fixed 3s gap kept a git process running almost continuously while the +// workspace was idle (#7983). Evidence-free ticks wait 5x the previous poll +// duration (~1/6 duty cycle); change signals wait only 1x so real changes in a +// slow repo still surface promptly; the cap bounds worst-case staleness. +const SLOW_GIT_POLL_BACKOFF = { + idleMultiplier: 5, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 +} export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true @@ -100,6 +110,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) const runFetchStatus = useCallback(async () => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the scan instead of running tens of seconds of git work nobody can see. + // The becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { return } @@ -144,7 +160,8 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const statusPollRunnerRef = useRef(null) useEffect(() => { const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS + minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF }) statusPollRunnerRef.current = runner return () => { @@ -157,24 +174,33 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { statusPollRunnerRef.current?.run() }, []) + // Why: file-watch and push signals carry evidence something changed, so they + // take the runner's short-backoff lane instead of evidence-free tick pacing. + const fetchStatusOnChangeSignal = useCallback(() => { + statusPollRunnerRef.current?.run({ changeSignal: true }) + }, []) + useEffect(() => { if (!activeStatusPollScope) { return } // Why: this root-level poll should pause while hidden, but visible // unfocused windows still need fresh status for second-display workflows. + // Change signals are dropped while hidden, so the becoming-visible run + // rides the short-backoff lane to catch up on anything that was missed. return installWindowVisibilityInterval({ run: fetchStatus, + runOnVisible: fetchStatusOnChangeSignal, intervalMs: activeStatusPollIntervalMs }) - }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus]) + }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus, fetchStatusOnChangeSignal]) useGitStatusFileWatchRefresh({ activeConnectionId, activeRepoSupportsGit, activeWorktreeId, enabled, - fetchStatus, + fetchStatus: fetchStatusOnChangeSignal, gitStatusHugeByWorktree, isConnectionReady, openFiles, @@ -188,7 +214,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { activeRepoId, activeWorktreeId, enabled: shouldPollActiveWorktreeGitStatus, - fetchStatus + fetchStatus: fetchStatusOnChangeSignal }) // Why: poll conflict operation for non-active worktrees that have a stale @@ -203,6 +229,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { } const pollStale = async (): Promise => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the probe instead of running SSH/RPC work nobody can see. The + // becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } for (const { id, path } of staleConflictWorktrees) { try { const connectionId = getConnectionId(id) ?? undefined @@ -225,13 +257,18 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { } // Why: remote conflict probes can exceed the 3s interval. Keep one poll in - // flight and coalesce skipped ticks into one trailing pass so stale badges - // catch up without stacking SSH/RPC work. - const pollRunner = createCoalescedPollRunner(pollStale) + // flight, coalesce skipped ticks into one trailing pass, and back off after + // slow probe chains so stale badges catch up without stacking SSH/RPC work. + const pollRunner = createCoalescedPollRunner(pollStale, { + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + }) // Why: conflict badges are visible sidebar state; keep them fresh in // visible unfocused windows, but do not poll disconnected hidden windows. + // The becoming-visible run rides the short-backoff lane so badges catch + // up promptly after a hidden stretch. const stopVisiblePoll = installWindowVisibilityInterval({ run: () => pollRunner.run(), + runOnVisible: () => pollRunner.run({ changeSignal: true }), intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) return () => { diff --git a/src/renderer/src/lib/window-visibility-interval.test.ts b/src/renderer/src/lib/window-visibility-interval.test.ts index cd4a6eb8bc2..cb870794cd8 100644 --- a/src/renderer/src/lib/window-visibility-interval.test.ts +++ b/src/renderer/src/lib/window-visibility-interval.test.ts @@ -59,6 +59,51 @@ describe('installWindowVisibilityInterval', () => { ) }) + it('uses runOnVisible for the becoming-visible run and run for interval ticks', () => { + let visibilityState: DocumentVisibilityState = 'hidden' + const documentListeners = new Map void>() + const intervalCallbacks: (() => void)[] = [] + const setIntervalMock = vi.fn((callback: () => void) => { + intervalCallbacks.push(callback) + return 1 as unknown as ReturnType + }) + const run = vi.fn() + const runOnVisible = vi.fn() + + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((event: string, listener: () => void) => { + documentListeners.set(event, listener) + }), + removeEventListener: vi.fn() + }) + + const cleanup = installWindowVisibilityInterval({ + run, + runOnVisible, + intervalMs: 3000, + setIntervalFn: setIntervalMock, + clearIntervalFn: vi.fn() + }) + + visibilityState = 'visible' + documentListeners.get('visibilitychange')?.() + expect(runOnVisible).toHaveBeenCalledTimes(1) + expect(run).not.toHaveBeenCalled() + + intervalCallbacks.at(0)?.() + expect(run).toHaveBeenCalledTimes(1) + expect(runOnVisible).toHaveBeenCalledTimes(1) + + cleanup() + }) + it('starts while visible even when the window is not focused', () => { const run = vi.fn() const setIntervalMock = vi.fn(() => 1 as unknown as ReturnType) diff --git a/src/renderer/src/lib/window-visibility-interval.ts b/src/renderer/src/lib/window-visibility-interval.ts index 966fc91dc96..bdd250d5c16 100644 --- a/src/renderer/src/lib/window-visibility-interval.ts +++ b/src/renderer/src/lib/window-visibility-interval.ts @@ -10,6 +10,10 @@ export function isWindowVisible(): boolean { export function installWindowVisibilityInterval(args: { run: () => void + // Why: callers that drop refresh signals while hidden can treat the + // becoming-visible run as evidence-bearing (something may have been missed) + // instead of a bare interval tick. Defaults to `run`. + runOnVisible?: () => void intervalMs: number setIntervalFn?: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer clearIntervalFn?: (handle: WindowVisibilityIntervalTimer) => void @@ -33,7 +37,7 @@ export function installWindowVisibilityInterval(args: { if (intervalId || !isWindowVisible()) { return } - args.run() + ;(args.runOnVisible ?? args.run)() // Why: many callers shell out or cross IPC. Keep their interval alive only // while Orca can present the refreshed data, but still refresh a visible // unfocused window so status UI does not go stale on a second display. From 7a733862cb5d5c8b5b420cdcbe353138dc2cfed1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:22:06 -0700 Subject: [PATCH 24/25] Harden cleanup removal review findings (#8010) - Fail queued removals that reveal concrete git risk (dirty files or unpushed commits) discovered after an unverifiable force approval. - Clear a failed row's queued-for-deletion sidebar overlay as soon as the row fails instead of when the whole batch settles (new onRowFailed). - Skip the auto-scan on dialog reopen while a removal batch is running; the removal's scan invalidation would discard it immediately. Co-authored-by: Orca --- .../WorkspaceCleanupDialog.tsx | 33 ++++++++----- ...rkspace-cleanup-background-removal.test.ts | 35 ++++++++++++++ .../workspace-cleanup-background-removal.ts | 48 +++++++++++++------ ...orkspace-cleanup-removal-preflight.test.ts | 37 ++++++++++++++ .../src/store/slices/workspace-cleanup.ts | 39 +++++++++------ 5 files changed, 153 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 91a552735ef..d5ec61ca3f3 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -307,7 +307,10 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setSelectedIds(new Set()) } } - if (!loading && !autoScanAttemptedForOpenRef.current) { + // Why: reopening mid-batch keeps the deletion progress view; a broad scan + // started here would be discarded by the removal's scan invalidation, so + // skip it while a removal batch is running (matches the reset guard above). + if (!loading && !autoScanAttemptedForOpenRef.current && !removalInFlightRef.current) { autoScanAttemptedForOpenRef.current = true startWorkspaceCleanupScan({ notifyWhenReady: true }) } @@ -523,6 +526,19 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setConfirmCandidates([]) }, [closeModal, removalProgress]) + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: candidates that fail before removal starts would otherwise stay + // marked "Queued for deletion" in the sidebar; rows already in the + // 'deleting' phase or failed with an error keep their own state. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + const confirmRemove = useCallback(() => { if (confirmCandidates.length === 0 || removalInFlightRef.current) { return @@ -547,20 +563,14 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setRemovalProgress(progress) } }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, onResult: (result) => { const nextFailures: Record = {} for (const failure of result.failures) { nextFailures[failure.worktreeId] = failure.message - const deleteState = useAppStore.getState().deleteStateByWorktreeId[failure.worktreeId] - if ( - deleteState?.isDeleting && - deleteState.error === null && - deleteState.phase === 'queued' - ) { - // Why: candidates that fail before removal starts would otherwise - // stay marked "Queued for deletion" in the sidebar indefinitely. - clearWorktreeDeleteState(failure.worktreeId) - } + clearQueuedDeleteState(failure.worktreeId) } if (mountedRef.current) { setRowFailures(nextFailures) @@ -592,6 +602,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } }) }, [ + clearQueuedDeleteState, clearWorktreeDeleteState, confirmCandidates, markWorktreesQueuedForDeletion, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index ec3a4114a97..abe1c5ab396 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -214,6 +214,41 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) }) + it('reports each failure as it happens so queued rows can clear before the batch ends', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onRowFailed = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onRowFailed + }) + await settleBackgroundRemoval() + + // Child fails at removal (per-row result), then parent is skipped as its + // ancestor — both reported incrementally, not only in the final result. + expect(onRowFailed.mock.calls.map(([failure]) => failure.worktreeId)).toEqual([ + child.worktreeId, + parent.worktreeId + ]) + }) + it('does not skip same-path ancestors from another connection after a nested failure', async () => { const failedChild = makeCandidate({ worktreeId: 'repo-1::/repo/parent/child', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index a8d14923cba..9823aa04033 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -29,6 +29,10 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { onProgress: (progress: WorkspaceCleanupRemovalProgress) => void onResult?: (result: WorkspaceCleanupRemoveResult) => void onError?: (error: unknown) => void + // Why: a row can fail before its removal starts (preflight failure or a + // skipped nested workspace); report it now so its queued overlay can clear + // instead of waiting for the whole batch to settle. + onRowFailed?: (failure: WorkspaceCleanupFailure) => void removalTimeoutMs?: number } @@ -38,6 +42,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ onProgress, onResult, onError, + onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { @@ -64,6 +69,17 @@ export function startWorkspaceCleanupBackgroundRemoval({ }) } + const reportFailures = (rowFailures: readonly WorkspaceCleanupFailure[]): void => { + for (const failure of rowFailures) { + failures.push(failure) + try { + onRowFailed?.(failure) + } catch (callbackError) { + console.error('Workspace cleanup row failure callback failed', callbackError) + } + } + } + emitProgress() // Why: keep the store's nested-worktree delete invariant even though progress @@ -78,14 +94,16 @@ export function startWorkspaceCleanupBackgroundRemoval({ ) ) { failedCandidates.push(candidate) - failures.push({ - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: translate( - 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', - 'Skipped because a nested workspace could not be removed.' - ) - }) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', + 'Skipped because a nested workspace could not be removed.' + ) + } + ]) processedCount += 1 emitProgress() continue @@ -97,17 +115,19 @@ export function startWorkspaceCleanupBackgroundRemoval({ removalTimeoutMs ) removedIds.push(...result.removedIds) - failures.push(...result.failures) + reportFailures(result.failures) if (result.failures.length > 0) { failedCandidates.push(candidate) } } catch (error: unknown) { failedCandidates.push(candidate) - failures.push({ - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: error instanceof Error ? error.message : String(error) - }) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + } + ]) } finally { processedCount += 1 emitProgress() diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index 745e1a32298..cae7d7c8fce 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -369,6 +369,43 @@ describe('workspace cleanup removal and protection', () => { }) }) + it('fails a removal that reveals concrete git risk after an unverified force approval', async () => { + const approvedCandidate = makeCandidate({ + tier: 'review', + blockers: ['git-status-error'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const nowRevealsUnpushed = makeCandidate({ + tier: 'review', + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 3, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [nowRevealsUnpushed], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ + removedIds: [], + failures: [ + { + worktreeId: WORKTREE_ID, + displayName: 'old-workspace', + message: 'Workspace changed after confirmation. Refresh to review it before removing.' + } + ] + }) + expect(removeWorktree).not.toHaveBeenCalled() + }) + it('protects old workspaces when an agent process is still foregrounded', async () => { ;(globalThis as { window: unknown }).window = { api: { diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 52106424121..ed2e12d49be 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -79,6 +79,10 @@ type WorkspaceCleanupEnrichmentCacheEntry = { const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4 +// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base +// and git-status-error only mean "couldn't verify". A row approved while +// unverifiable must still fail if real work becomes visible before removal. +const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const let inFlightWorkspaceCleanupScan: { key: string @@ -857,20 +861,27 @@ async function preflightWorkspaceCleanupCandidate( // Why: this row may be removed minutes after the confirm click. If it now // needs a force removal the user never approved (new dirt, unpushed work, // or a git error since confirmation), fail it instead of force-deleting. - if ( - approvedCandidate && - shouldForceWorkspaceCleanupRemoval(candidate) && - !shouldForceWorkspaceCleanupRemoval(approvedCandidate) - ) { - return { - ok: false, - failure: { - worktreeId, - displayName: candidate.displayName, - message: translate( - 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', - 'Workspace changed after confirmation. Refresh to review it before removing.' - ) + if (approvedCandidate) { + const escalatedToForce = + shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate) + // Why: an approved row that was already force-flagged for an unverifiable + // reason must still fail when real dirt/unpushed work is now visible. + const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( + (blocker) => + candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) + ) + if (escalatedToForce || revealedConcreteRisk) { + return { + ok: false, + failure: { + worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', + 'Workspace changed after confirmation. Refresh to review it before removing.' + ) + } } } } From 946eb52e6bf6bd04665cf5652fb49c3cf53777b6 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 12:35:01 +0800 Subject: [PATCH 25/25] fix(terminal): make xterm scrollbar gutter transparent (#7876) * fix(terminal): style xterm scrollbar gutter * fix(terminal): reuse the canonical scrollbar style --- src/renderer/src/assets/main.css | 15 ++++++++++----- .../src/assets/terminal-scrollbar-style.test.ts | 17 +++++++++++++++++ src/renderer/src/assets/terminal.css | 4 ++-- 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/assets/terminal-scrollbar-style.test.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 44671b28a50..a85d7fb6c43 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -529,27 +529,32 @@ /* ── Editor-style scrollbar (matches Monaco) ────────── */ -.scrollbar-editor { +.scrollbar-editor, +.xterm .xterm-viewport { scrollbar-width: thin; scrollbar-color: rgba(121, 121, 121, 0.4) transparent; } -.scrollbar-editor::-webkit-scrollbar { +.scrollbar-editor::-webkit-scrollbar, +.xterm .xterm-viewport::-webkit-scrollbar { width: 14px; } -.scrollbar-editor::-webkit-scrollbar-track { +.scrollbar-editor::-webkit-scrollbar-track, +.xterm .xterm-viewport::-webkit-scrollbar-track { background: transparent; } -.scrollbar-editor::-webkit-scrollbar-thumb { +.scrollbar-editor::-webkit-scrollbar-thumb, +.xterm .xterm-viewport::-webkit-scrollbar-thumb { background: rgba(121, 121, 121, 0.4); border: 3px solid transparent; border-radius: 7px; background-clip: padding-box; } -.scrollbar-editor::-webkit-scrollbar-thumb:hover { +.scrollbar-editor::-webkit-scrollbar-thumb:hover, +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(121, 121, 121, 0.7); border: 3px solid transparent; background-clip: padding-box; diff --git a/src/renderer/src/assets/terminal-scrollbar-style.test.ts b/src/renderer/src/assets/terminal-scrollbar-style.test.ts new file mode 100644 index 00000000000..642678e264c --- /dev/null +++ b/src/renderer/src/assets/terminal-scrollbar-style.test.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), 'utf8') +const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8') + +describe('terminal scrollbar styling', () => { + it('reuses the canonical editor scrollbar with a transparent gutter', () => { + expect(mainCss).toMatch( + /\.scrollbar-editor,\s*\.xterm \.xterm-viewport\s*{[^}]*scrollbar-color:\s*rgba\(121, 121, 121, 0\.4\) transparent/s + ) + expect(mainCss).toMatch( + /\.scrollbar-editor::-webkit-scrollbar-track,\s*\.xterm \.xterm-viewport::-webkit-scrollbar-track\s*{[^}]*background:\s*transparent/s + ) + expect(terminalCss).not.toContain('--xterm-scrollbar-thumb') + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index d9763b3e341..31c2d142e43 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -36,8 +36,8 @@ the install terminals inside centered setup modals), that black layer anti-aliases into a faint 1px line above/below the themed .xterm. Match xterm's own selector so this also covers terminals mounted outside the pane - manager, and let the themed .xterm background show through; our scrollbar is - already custom-styled below. */ + manager, and let the themed .xterm background show through; the viewport + reuses the canonical editor scrollbar rules in main.css. */ .xterm:not(.allow-transparency) .xterm-viewport { background-color: transparent; }