From e30f9d36f287e797d8274f6276dbccb22e580a9f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:57:31 -0700 Subject: [PATCH 001/152] perf(renderer): purge seven per-tab/per-pty terminal maps on bulk worktree removal (#7613) --- ...-worktree-purge-terminal-maps-leak.test.ts | 127 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 34 +++++ 2 files changed, 161 insertions(+) create mode 100644 src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts new file mode 100644 index 00000000000..726e2b58fdc --- /dev/null +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -0,0 +1,127 @@ +/** + * Memory-leak regression: seven per-tab / per-pty terminal+agent store maps were + * evicted on the SINGLE removeWorktree path (via closeTab / shutdownWorktreeTerminals) + * but NOT on the BULK path — `buildWorktreePurgeState`, reached by removeProject, the + * external-worktree-removal authoritative-scan reconcile, and the hydration-stale + * reconcile. That path runs no terminal teardown, so before the fix each map stranded + * an entry per tab/pane of every externally-removed worktree for the renderer's whole + * session (unbounded across add/remove-repo cycles). tabId and ptyId are ephemeral, + * never-reused key spaces. + * + * Tab-keyed (evicted via the doomed-tab set): + * lastKnownRelayPtyIdByTabId, pendingInitialCwdByTabId, + * pendingIssueCommandSplitByTabId, pendingSetupSplitByTabId, pendingStartupByTabId + * Pty-keyed (evicted via the doomed-pty set derived from ptyIdsByTabId): + * codexRestartNoticeByPtyId, migrationUnsupportedByPtyId + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined) }, + pty: { kill: vi.fn().mockResolvedValue(undefined) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' +const PTY1 = 'pty-wt1' +const PTY2 = 'pty-wt2' + +function seedMaps(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1, ptyId: PTY1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2, ptyId: PTY2 })] + }, + // Drives the doomed-ptyId derivation in buildWorktreePurgeState. + ptyIdsByTabId: { [TAB1]: [PTY1], [TAB2]: [PTY2] }, + lastKnownRelayPtyIdByTabId: { [TAB1]: PTY1, [TAB2]: PTY2 }, + pendingInitialCwdByTabId: { [TAB1]: '/path/wt1', [TAB2]: '/path/wt2' }, + pendingIssueCommandSplitByTabId: { + [TAB1]: { command: 'a' }, + [TAB2]: { command: 'b' } + }, + pendingSetupSplitByTabId: { + [TAB1]: { command: 'setup-a', direction: 'vertical' }, + [TAB2]: { command: 'setup-b', direction: 'vertical' } + }, + pendingStartupByTabId: { [TAB1]: { command: 'start-a' }, [TAB2]: { command: 'start-b' } }, + codexRestartNoticeByPtyId: { + [PTY1]: { previousAccountLabel: 'a1', nextAccountLabel: 'a2' }, + [PTY2]: { previousAccountLabel: 'b1', nextAccountLabel: 'b2' } + }, + migrationUnsupportedByPtyId: { + [PTY1]: { + ptyId: PTY1, + paneKey: `${TAB1}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }, + [PTY2]: { + ptyId: PTY2, + paneKey: `${TAB2}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 2 + } + } + }) +} + +describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previously leaked', () => { + beforeEach(() => vi.clearAllMocks()) + + it('drops removed worktree entries (both tabId- and ptyId-keyed), retains survivors', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + const s = store.getState() + + // Removed worktree's tab/pty: every map evicted. + expect(s.lastKnownRelayPtyIdByTabId[TAB1]).toBeUndefined() + expect(s.pendingInitialCwdByTabId[TAB1]).toBeUndefined() + expect(s.pendingIssueCommandSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingSetupSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingStartupByTabId[TAB1]).toBeUndefined() + expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined() + expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined() + + // Surviving worktree's tab/pty: every entry retained (no over-eviction). + expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) + expect(s.pendingInitialCwdByTabId[TAB2]).toBe('/path/wt2') + expect(s.pendingIssueCommandSplitByTabId[TAB2]).toEqual({ command: 'b' }) + expect(s.pendingSetupSplitByTabId[TAB2]).toEqual({ command: 'setup-b', direction: 'vertical' }) + expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' }) + expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined() + expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 077deec381e..d6882728eea 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1829,12 +1829,20 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial() + // Why: some terminal/agent maps are keyed by ptyId, not tabId. Collect the + // doomed panes' ptyIds now (while ptyIdsByTabId is still populated) so this + // bulk path can evict them the same way shutdownWorktreeTerminals does on the + // single-removeWorktree path. + const doomedPtyIds = new Set() const doomedBrowserWorkspaceIds = new Set() const doomedPageIds = new Set() const removedFileIds = new Set() for (const id of worktreeIdSet) { for (const tab of s.tabsByWorktree[id] ?? []) { doomedTabIds.add(tab.id) + for (const ptyId of s.ptyIdsByTabId[tab.id] ?? []) { + doomedPtyIds.add(ptyId) + } // Why: a removed worktree's panes are gone for good, so drop their // hibernation output epochs from that module-level map (mirrors the // hosted-review prune above). A future pane mints a fresh leafId at epoch 0. @@ -1925,6 +1933,17 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial(obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const ptyId of doomedPtyIds) { + if (ptyId in out) { + delete out[ptyId] + changed = true + } + } + return changed ? out : obj + } // Pane-scoped maps are keyed by `${tabId}:${leafId}` (stable-pane-id); tabId // never contains ":", so the segment before the first ":" is the owning tab. const omitByPaneKeyTabPrefix = (obj: Record): Record => { @@ -2018,6 +2037,21 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial Date: Mon, 6 Jul 2026 18:03:36 -0700 Subject: [PATCH 002/152] feat(ai-vault): add OMP sessions to the AI Vault session browser (#7618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault): add OMP sessions to the AI Vault session browser Adds OMP ("Oh My Pi") to the AI Vault/Agents catalog so historical .omp/agent/sessions/**/*.jsonl transcripts are discovered, parsed, and resumable from the right-sidebar session browser — locally and over SSH. - Discovery mirrors Pi (OMP_CODING_AGENT_DIR env, WSL home roots, per-agent limit) in both the local scanner and the remote/SSH scanner. - Parses OMP's message-graph JSONL via the shared graph parser (new MessageGraphAgent type), capturing the model from model_change.model (OMP's key, not Pi's modelId) or the assistant message, and tokens from usage. - Routes OMP through the incremental parse cache so ~5s rescans resume from the last byte instead of re-reading whole transcripts. - Resumes by absolute transcript path (`omp --resume `) so it resolves regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL store) the file was discovered under; threaded through both the scanner and the renderer's local resume/copy rebuild. - Renderer reuses the existing OmpIcon/catalog/grouping; adds overflow-x-hidden so long worktree chips never widen the sidebar. Generalizes normalizePiSessionsDir -> normalizeAgentSessionsDir. Verified end-to-end against 10 real ~/.omp transcripts and rendered in the app. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> * fix: make AI Vault parse-cache agent switch explicit --------- Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../remote-session-scanner-sources.ts | 18 ++- .../ai-vault/session-scanner-accumulator.ts | 1 + .../ai-vault/session-scanner-agent-parser.ts | 2 + .../session-scanner-codex-workers.test.ts | 1 + .../ai-vault/session-scanner-graph-parsers.ts | 17 ++- .../session-scanner-incremental-fixtures.ts | 51 ++++++++ ...canner-opencode-sqlite-coexistence.test.ts | 3 +- .../ai-vault/session-scanner-parse-cache.ts | 12 +- .../session-scanner-source-discovery.ts | 29 ++++- .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + .../ai-vault/session-scanner-values.test.ts | 17 +++ src/main/ai-vault/session-scanner-values.ts | 11 +- src/main/ai-vault/session-scanner.test.ts | 116 ++++++++++++------ .../AiVaultSessionVirtualList.tsx | 5 +- .../src/lib/ai-vault-resume-command.test.ts | 23 ++++ .../src/lib/ai-vault-resume-command.ts | 8 +- src/shared/ai-vault-resume-command.test.ts | 69 +++++++++++ src/shared/ai-vault-types.ts | 15 ++- 19 files changed, 338 insertions(+), 62 deletions(-) create mode 100644 src/shared/ai-vault-resume-command.test.ts diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index f232b0adf81..e8d43ae1f08 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -15,7 +15,7 @@ import { parseHermesSessionContent } from './session-scanner-secondary-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' import type { RemoteParserOptions, @@ -83,6 +83,7 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), jsonlSource( 'droid', remoteHome, @@ -203,6 +204,15 @@ function piParser( return parseMessageGraphSessionContent('pi', file, content, platform, options) } +function ompParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options) +} + function openClawParser( file: FileWithMtime, content: string, @@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] { } function remotePiSessionsSegments(): string[] { - return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) } diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index c31408a8496..8e44e30c60b 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -113,6 +113,7 @@ export function finalizeSession( resumeCommand: buildAiVaultResumeCommand({ agent: accumulator.agent, sessionId, + resumeFilePath: accumulator.filePath, cwd: accumulator.cwd, platform, codexHome: options.codexHome diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1114c1b8bb2..5a81ff260d0 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -64,6 +64,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('openclaw', candidate.file, platform) case 'pi': return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'omp': + return parseMessageGraphSessionFile('omp', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 585e37b0203..8a6fb183f62 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 01d84ddfac0..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str return extractContentText(textParts) } +// Agents whose transcripts are append-only message-graph JSONL (session + +// model_change + message records). OMP is a Pi fork and shares the format. +export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' + export async function parseMessageGraphSessionFile( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { @@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile( } export async function parseMessageGraphSessionContent( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, @@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st return } if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model + // Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress + // session shows its model before the first assistant reply lands. + accumulator.model = + extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model return } if (record.type !== 'message') { @@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st } export function createMessageGraphSessionResumeState( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { return accumulatorFoldResumeState( @@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState( } async function parseMessageGraphSessionLines(args: { - agent: 'openclaw' | 'pi' + agent: MessageGraphAgent file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 01219836a58..1945193e6c7 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture { } } +// OMP is a Pi fork sharing the message-graph format, but keys the model on +// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp' +// branch and that model-key difference. (The session `title` is included for +// realism only — the parser derives the title from the first user message.) +export function ompFixture(): IncrementalAgentFixture { + return { + agent: 'omp', + fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + title: 'OMP session title', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'omp incremental answer' }], + model: 'gpt-5.4-mini', + usage: { input: 40, output: 20, totalTokens: 60 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'cccccccc-dddd-4eee-8fff-000000000000', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + export function geminiJsonlFixture(): IncrementalAgentFixture { return { agent: 'gemini', @@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { droidFixture(), openclawFixture(), piFixture(), + ompFixture(), geminiJsonlFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index d0eaa649818..2fb3df21613 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) { piSessionsDir: join(root, 'pi-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + ompSessionsDir: join(root, 'omp-sessions') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index dcc7fc649aa..20650f105a9 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -59,7 +59,8 @@ function resumableStateFactoryFor( case 'droid': return () => createDroidSessionResumeState(candidate.file) case 'openclaw': - case 'pi': { + case 'pi': + case 'omp': { const agent = candidate.agent return () => createMessageGraphSessionResumeState(agent, candidate.file) } @@ -67,7 +68,12 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null - default: + case 'devin': + case 'grok': + case 'hermes': + case 'kimi': + case 'opencode': + case 'rovo': return null } } @@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void { /** * Parse a session file, reusing prior work where the file is provably * unchanged (mtime+size) and, for append-only JSONL transcripts (Claude, - * Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the + * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the * parse from the last consumed byte when the file only grew. This is what * keeps the renderer's ~5s forced rescans from re-reading gigabytes of * transcripts (STA-1278/STA-1417: main process pegging one core during diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 12acdeab188..8c03ec72e42 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join( const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizePiSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +const OMP_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' ) // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( @@ -124,7 +129,8 @@ function standardDiscoveries( ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues) + ...piDiscoveries(options, wslHomeDirs, limit, issues), + ...ompDiscoveries(options, wslHomeDirs, limit, issues) ] } @@ -244,6 +250,21 @@ function piDiscoveries( ) } +function ompDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]).map((rootDir) => + discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] }) + ) +} + function openClawDiscovery( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index b7c4dfd05cf..0a3b6c7c1a8 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7c6c38a0be3..5364e29e21e 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -25,6 +25,7 @@ export type AiVaultScanOptions = { openclawStateDir?: string openclawLegacyStateDir?: string piSessionsDir?: string + ompSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 9c1de341d28..a9954ae8d26 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { extractPreviewContentText, + normalizeAgentSessionsDir, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + + it('expands Pi and OMP agent homes to their session directories', () => { + expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe( + '/agents/.pi/agent/sessions' + ) + + expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + }) }) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 9733175e360..39006bd68a4 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null { return dirname(sessionRoot) } -export function normalizePiSessionsDir(rawValue: string): string { +// Pi and OMP (a Pi fork) both store transcripts under +// //agent/sessions; accept any prefix of that path. +export function normalizeAgentSessionsDir( + rawValue: string, + agentHomeDirName: '.pi' | '.omp' +): string { const trimmed = rawValue.trim() if (!trimmed) { - return join(homedir(), '.pi', 'agent', 'sessions') + return join(homedir(), agentHomeDirName, 'agent', 'sessions') } const normalized = trimmed.replace(/[\\/]+$/, '') const leaf = basename(normalized) @@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string { if (leaf === 'agent') { return join(normalized, 'sessions') } - if (leaf === '.pi') { + if (leaf === agentHomeDirName) { return join(normalized, 'agent', 'sessions') } return normalized diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 41b1b2b05ab..d4404341fc1 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => { ]) ) + const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl') + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + ompSessionFile, + jsonLines([ + { + type: 'session', + version: 3, + id: 'omp-session', + title: 'OMP session title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:08:30.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:31.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'OMP answer' }], + model: 'gpt-5.4-mini', + // totalTokens deliberately != input+output so the assertion proves + // the explicit-total field is read, not an input/output sum. + usage: { input: 10, output: 5, totalTokens: 160 } + } + } + ]) + ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( join(roots.devinTranscriptsDir, 'devin-session.json'), @@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + // OMP resumes by absolute transcript path, not by internal session id. + expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`) expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) + + const ompSession = result.sessions.find((session) => session.agent === 'omp') + expect(ompSession?.model).toBe('gpt-5.4-mini') + expect(ompSession?.totalTokens).toBe(160) + }) + + it('captures an in-progress OMP model from model_change before any assistant reply', async () => { + // OMP writes the model on `model_change.model` (not Pi's `modelId`). With no + // assistant message yet, the model must still come through — proving the + // model_change fallback rather than assistant-message capture. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + join(roots.ompSessionsDir, 'omp-in-progress.jsonl'), + jsonLines([ + { + type: 'session', + id: 'omp-in-progress', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/omp' + }, + { type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' }, + { + type: 'message', + timestamp: '2026-05-01T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 }) + const session = result.sessions.find((s) => s.agent === 'omp') + expect(session?.model).toBe('omp-mc-only-model') }) it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { @@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => { expect(usedGrokWrapperMatch).toBe(false) }) }) - -describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32' - }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') - }) - - it('carries non-default Codex homes in copied resume commands', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: '/repo/app', - platform: 'darwin', - codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' - }) - ).toBe( - "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" - ) - - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32', - codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' - }) - ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' - ) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index a8de4d84313..545b4095544 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -154,7 +154,10 @@ export function AiVaultSessionVirtualList({ }) return ( -
+
{loading && sessionsCount === 0 ? : null} {!loading && sessionsCount === 0 && !error ? ( diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index dc0345491bf..d6be594878e 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,6 +123,29 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) + it('resumes a local OMP session by its absolute transcript path, not its id', () => { + // Regression: local rebuilds must forward session.filePath so OMP resumes by + // path (not the internal id) even when the session lives in a non-default store. + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + const command = buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + + expect(command).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + ) + expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') + }) + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { // Regression guard: the copy path is self-contained for pasting into cmd.exe // and must stay cmd-wrapped even though the queued path now follows the shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 691d69efc09..ce23324c02a 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -26,7 +26,9 @@ type AiVaultResumeCommandSession = Pick< AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome' > & - Partial> + Partial< + Pick + > export type AiVaultResumeStartup = { command: string @@ -127,6 +129,10 @@ export function buildAiVaultResumeStartupForWorktree(args: { command: buildAiVaultResumeCommand({ agent: args.session.agent, sessionId: args.session.sessionId, + // Why: OMP resumes by absolute transcript path, so local rebuilds must + // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store + // session would resume by id against the default store and miss. + resumeFilePath: args.session.filePath, cwd: args.session.cwd, platform, commandOverride: args.commandOverride, diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts new file mode 100644 index 00000000000..101237ce1c3 --- /dev/null +++ b/src/shared/ai-vault-resume-command.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { buildAiVaultResumeCommand } from './ai-vault-types' + +describe('buildAiVaultResumeCommand', () => { + it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') + }) + + it('carries non-default Codex homes in copied resume commands', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' + }) + ).toBe( + "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" + ) + + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' + ) + }) + + it('resumes OMP by absolute transcript path so it resolves across session-dir roots', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: + '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe( + "cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl'" + ) + }) + + it('falls back to the session id when no OMP transcript path is known', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: null, + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'") + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 58bf82597ab..4ba2c8820cb 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -12,6 +12,7 @@ export const AI_VAULT_AGENTS = [ 'codex', 'hermes', 'pi', + 'omp', 'cursor', 'gemini', 'rovo', @@ -34,6 +35,7 @@ export const AI_VAULT_AGENT_LABELS = { codex: 'Codex', hermes: 'Hermes', pi: 'Pi', + omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', rovo: 'Rovo Dev', @@ -102,10 +104,16 @@ export function buildAiVaultResumeCommand(args: { platform: NodeJS.Platform commandOverride?: string | null codexHome?: string | null + resumeFilePath?: string | null }): string { - const { agent, sessionId, cwd, platform, commandOverride, codexHome } = args + const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath } = args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) - const sessionArg = quoteShellArg(sessionId, platform) + // Why: OMP's `--resume` accepts an absolute transcript path, which resolves + // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL + // home) the file was discovered under, where an id-prefix lookup scoped to + // the default store would miss it. Falls back to the id if no path is known. + const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId + const sessionArg = quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome }) @@ -222,6 +230,9 @@ function buildAgentResumeInvocation( case 'devin': case 'openclaw': case 'droid': + // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), + // but the `--resume ` invocation form is identical to the others here. + case 'omp': return `${baseCommand} --resume ${sessionArg}` } } From 1886ea27f7300a46485e755e04b04bb6c00d9bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eren=20=C3=87akar?= Date: Thu, 2 Jul 2026 23:17:57 +0300 Subject: [PATCH 003/152] fix: use runtime agent IDs in workspace composer (#7082) --- src/renderer/src/hooks/useComposerState.ts | 31 ++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dc6e1aa1957..d1316b98678 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1122,20 +1122,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [tuiAgent, setTuiAgent] = useState( persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent ) - // Why: when the selected repo is remote (has a connectionId), read the - // per-connection agent list instead of the local one. This ensures the - // Create Workspace dialog shows agents installed on the SSH host, not the - // local machine. + // Why: when the selected repo has a connectionId or runtime environment, read + // the per-host agent list instead of the local one. This ensures the Create + // Workspace dialog shows agents installed on the SSH host or paired runtime, + // not the local machine. const connectionId = selectedRepoConnectionId const isRemote = typeof connectionId === 'string' + const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null const detectedAgentList = useAppStore((s) => { if (isRemote) { return s.remoteDetectedAgentIds[connectionId] ?? null } + if (runtimeEnvironmentId) { + return s.runtimeDetectedAgentIds[runtimeEnvironmentId] ?? null + } return s.detectedAgentIds }) const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents) const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents) + const ensureRuntimeDetectedAgents = useAppStore((s) => s.ensureRuntimeDetectedAgents) const detectedAgentIds = useMemo | null>( () => (detectedAgentList ? new Set(detectedAgentList) : null), [detectedAgentList] @@ -1696,14 +1701,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ]) // Why: detect agents for the selected repo. For local repos this runs once - // on mount (deduped by the store). For remote repos it re-runs when the - // selected repo changes so the agent list matches the SSH host. + // on mount (deduped by the store). For remote/runtime repos it re-runs when + // the selected repo changes so the agent list matches the correct host. useEffect(() => { if (isRemote && selectedRepoSshStatus !== 'connected') { return } let cancelled = false - const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents() + const detect = isRemote + ? ensureRemoteDetectedAgents(connectionId) + : runtimeEnvironmentId + ? ensureRuntimeDetectedAgents(runtimeEnvironmentId) + : ensureDetectedAgents() void detect.then((ids) => { if (cancelled) { return @@ -1722,11 +1731,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - // Why: re-run when connectionId changes (user picks a different repo) so - // detection targets the correct host. Draft/settings deps are intentionally - // excluded — detection is a best-effort PATH snapshot. + // Why: re-run when connectionId/runtimeEnvironmentId changes (user picks a + // different repo) so detection targets the correct host. Draft/settings deps + // are intentionally excluded — detection is a best-effort PATH snapshot. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectionId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) + }, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) // Per-repo: load yaml hooks + issue command template. useEffect(() => { From d84041c52aa1cccb5493687ec2ce1f507b6eab2c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 6 Jul 2026 18:48:28 -0400 Subject: [PATCH 004/152] test: lock composer agent-detection host precedence for runtime repos (#7082) Regression guard for the runtime-agent-list fix: asserts the composer's detectedAgentList selector and detection effect resolve SSH -> runtime -> local in that order, so a runtime-owned repo shows the paired runtime's agents instead of the local machine's. Co-authored-by: Orca --- ...poserState-host-context-boundaries.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 00aa3fe4fea..51b552f3436 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -159,6 +159,40 @@ describe('useComposerState host-context boundaries', () => { expect(submitSection).toContain('runtimeEnvironmentId: folderTargetRuntimeEnvironmentId') }) + it('detects composer agents against the repo host: SSH, then runtime, then local (#7082)', () => { + // Why: a repo owned by a paired runtime must show the runtime's agents, not + // the local machine's. SSH stays first priority; runtime falls through before + // local so an SSH repo never double-detects. Regression guard for #7082. + const selectorSection = sourceBetween( + HOOK_SOURCE, + 'const detectedAgentList = useAppStore', + 'const ensureDetectedAgents = useAppStore' + ) + expect(selectorSection).toContain('if (isRemote) {') + expect(selectorSection).toContain('s.remoteDetectedAgentIds[connectionId]') + expect(selectorSection).toContain('if (runtimeEnvironmentId) {') + expect(selectorSection).toContain('s.runtimeDetectedAgentIds[runtimeEnvironmentId]') + expect(selectorSection).toContain('return s.detectedAgentIds') + // SSH branch is checked before the runtime branch. + expect(selectorSection.indexOf('if (isRemote) {')).toBeLessThan( + selectorSection.indexOf('if (runtimeEnvironmentId) {') + ) + + expect(HOOK_SOURCE).toContain( + 'const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null' + ) + + // Detection effect fans out to the same three hosts in the same order and + // re-runs when the runtime environment changes. + const detectSection = sourceBetween(HOOK_SOURCE, 'const detect = isRemote', 'void detect.then') + expect(detectSection).toContain('ensureRemoteDetectedAgents(connectionId)') + expect(detectSection).toContain('ensureRuntimeDetectedAgents(runtimeEnvironmentId)') + expect(detectSection).toContain('ensureDetectedAgents()') + expect(HOOK_SOURCE).toContain( + '}, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents])' + ) + }) + it('seeds initial workspace run target from the task source context', () => { expect( resolveInitialWorkspaceRunSeed({ From 51c3849a002529e50a02a920125bd65396ce2225 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:34:02 -0300 Subject: [PATCH 005/152] fix(github): route PR merges through repo owner (#7590) * fix(github): route PR merges through repo owner - Use repo-owner runtime settings before source-context overrides in PR actions - Keep headless/runtime repos on runtime RPC instead of local gh:mergePR - Add source-boundary coverage for merge routing and Windows-safe newline matching * fix(github): preserve repo-owner merge routing - Apply task-source runtime overrides only when they resolve to a runtime host - Keep runtime-owned repos from being downgraded to local gh:mergePR - Guard the routing contract with source-boundary coverage - Important because non-runtime source contexts could recreate the issue #6957 access-denied path * test(github): add behavioral regression test for repo-owner merge routing Mirrors PRActionsPanel's merge-routing decision through the real repo-runtime-owner/task-source helpers so a revert to source-only routing (issue #6957) fails here. Covers the runtime-owned repo with a local GitHub source view and the local-repo-while-runtime-focused case. Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../src/components/GitHubItemDialog.tsx | 16 ++++- .../github-item-dialog-merge-routing.test.ts | 71 +++++++++++++++++++ ...github-item-dialog-source-boundary.test.ts | 12 +++- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/github-item-dialog-merge-routing.test.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 863928204b0..5d45f1f78ea 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -3848,7 +3848,21 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = getTaskSourceRuntimeSettings(sourceContext) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null)) + ) + const sourceSettings = useMemo(() => { + if (sourceContext?.provider !== 'github') { + return repoOwnerSettings + } + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? ({ + ...repoOwnerSettings, + ...sourceRuntimeSettings + } as typeof repoOwnerSettings) + : repoOwnerSettings + }, [repoOwnerSettings, sourceContext]) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' diff --git a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts new file mode 100644 index 00000000000..8374fe94ba9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + getSettingsForRepoRuntimeOwner, + type RepoRuntimeOwnerState +} from '../lib/repo-runtime-owner' +import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' + +// Why: mirrors the merge-routing decision inside GitHubItemDialog's +// PRActionsPanel so a revert to source-only routing (issue #6957) fails here. +// A runtime-owned repo whose GitHub source view is local must still merge on +// the owner runtime, not fall back to local `gh:mergePR`. +function resolveMergeTarget( + state: RepoRuntimeOwnerState, + repoId: string | null, + sourceContext: TaskSourceContext | null +): ReturnType { + const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId) + const sourceSettings = + sourceContext?.provider !== 'github' + ? repoOwnerSettings + : (() => { + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? { ...repoOwnerSettings, ...sourceRuntimeSettings } + : repoOwnerSettings + })() + return getActiveRuntimeTarget(sourceSettings) +} + +function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext { + return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' } +} + +describe('GitHubItemDialog PR merge routing', () => { + const runtimeOwnedRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + } + + it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('routes a runtime-owned repo to its owner runtime when there is no source context', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('lets a runtime source override the repo owner when both are runtimes', () => { + expect( + resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime')) + ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) + }) + + it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => { + const localRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + } + expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'local' + }) + }) +}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index deb287ca2d3..740d65f67cf 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -91,8 +91,8 @@ describe('GitHubItemDialog source host boundaries', () => { expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' + expect(matchInvalidationSection).toMatch( + /if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/ ) }) @@ -190,7 +190,13 @@ describe('GitHubItemDialog source host boundaries', () => { 'function CommentReactions' ) - expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId') + expect(actionsSection).toContain('...repoOwnerSettings') + expect(actionsSection).toContain( + 'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)' + ) + expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId') + expect(actionsSection).toContain('...sourceRuntimeSettings') expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') expect(actionsSection).toContain( 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' From 0b4196fc1786034b46fb24fd73b47eed21c4400e Mon Sep 17 00:00:00 2001 From: PP <128917720+huhuhulululu@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:35:42 -0400 Subject: [PATCH 006/152] fix(ssh): stop deleted ~/.ssh/config hosts from reappearing on sync (#7302) Deleting a config-sourced SSH target had no lasting effect: the Manage-SSH pane re-imports ~/.ssh/config on open, and the import was a pure upsert with no record of deletions, so the just-deleted host was re-inserted verbatim from the config that still exists on disk. Persist a `deletedSshConfigAliases` tombstone set: - Deleting a config-managed target (source 'ssh-config', or an adopted legacy import) records its alias; manual targets are never tombstoned. - The passive on-open sync skips tombstoned aliases, so a deleted host stays deleted. - Re-adding or editing a target reclaims its alias, and the explicit Import action (`reAdopt`) clears all tombstones to deliberately re-adopt config. This also fixes the edit-then-reappear case: editing a config host to `manual` already reserved its current alias, and reclaim covers alias changes. --- src/main/ipc/ssh.ts | 4 +- src/main/persistence.ts | 33 +++++ src/main/ssh/ssh-connection-store.test.ts | 115 ++++++++++++++++++ src/main/ssh/ssh-connection-store.ts | 52 +++++++- src/preload/api-types.ts | 2 +- src/preload/index.ts | 3 +- .../src/components/settings/SshPane.tsx | 5 +- src/shared/constants.ts | 1 + src/shared/types.ts | 4 + 9 files changed, 212 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..0ac0c5e0db0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -714,8 +714,8 @@ export function registerSshHandlers( await removeRegisteredSshTarget(args.id) }) - ipcMain.handle('ssh:importConfig', () => { - return sshStore!.importFromSshConfig() + ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { + return sshStore!.importFromSshConfig(args) }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 157ab31f011..5bad9510e65 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3339,6 +3339,11 @@ export class Store { defaults.workspaceSession ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter( + (alias): alias is string => typeof alias === 'string' + ) + : [], sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), @@ -5973,6 +5978,34 @@ export class Store { this.scheduleSave() } + getDeletedSshConfigAliases(): string[] { + return [...(this.state.deletedSshConfigAliases ?? [])] + } + + addDeletedSshConfigAlias(alias: string): void { + this.state.deletedSshConfigAliases ??= [] + if (!this.state.deletedSshConfigAliases.includes(alias)) { + this.state.deletedSshConfigAliases.push(alias) + this.scheduleSave() + } + } + + removeDeletedSshConfigAlias(alias: string): void { + const current = this.state.deletedSshConfigAliases + if (!current || !current.includes(alias)) { + return + } + this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) + this.scheduleSave() + } + + clearDeletedSshConfigAliases(): void { + if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { + this.state.deletedSshConfigAliases = [] + this.scheduleSave() + } + } + // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 8e3e347d709..f1bf80d17aa 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({ function createMockStore() { const targets: SshTarget[] = [] + let deletedAliases: string[] = [] return { getSshTargets: vi.fn(() => [...targets]), @@ -32,6 +33,18 @@ function createMockStore() { if (idx !== -1) { targets.splice(idx, 1) } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] }) } } @@ -324,4 +337,106 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) }) + + describe('deleted config host tombstones', () => { + function candidate(overrides: Partial & { configHost: string }): SshTarget { + return { + id: `tmp-${overrides.configHost}`, + label: overrides.configHost, + host: `${overrides.configHost}.example.com`, + port: 22, + username: '', + ...overrides + } + } + + // PRIMARY regression: deleting a config-sourced host must not let the next + // ~/.ssh/config sync resurrect it. + it('tombstones a deleted config-sourced host so re-import skips it', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert + expect(result).toEqual([]) + }) + + it('does not tombstone a manual target on delete', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + }) + + it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.addTarget({ + label: 'mini', + configHost: 'mini', + host: '10.0.0.2', + port: 22, + username: 'ping' + }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + // Alias reclaimed, but it is now a manual target — still not re-inserted. + const result = sshStore.importFromSshConfig() + expect(result).toEqual([]) + }) + + it('editing a target reclaims its alias from the deleted set', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + }) + + it('reAdopt clears all tombstones and re-imports the deleted host', () => { + mockStore.addDeletedSshConfigAlias('mini') + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig({ reAdopt: true }) + + expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled() + expect(mockStore.addSshTarget).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'mini', source: 'ssh-config' }) + ) + expect(result).toHaveLength(1) + }) + }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index d65bd42b9f0..975da8cdbc2 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -23,6 +23,9 @@ export class SshConnectionStore { source: target.source ?? 'manual', id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } + // Why: re-adding a host the user previously deleted is an explicit intent to + // keep it — lift any tombstone so config sync stops suppressing this alias. + this.reclaimAlias(full.configHost ?? full.label) this.store.addSshTarget(full) return full } @@ -51,19 +54,49 @@ export class SshConnectionStore { } updateTarget(id: string, updates: Partial>): SshTarget | null { - return this.store.updateSshTarget(id, updates) + const updated = this.store.updateSshTarget(id, updates) + if (updated) { + // Why: actively editing a target reclaims its alias from the deleted set, + // so an edit can never leave the host tombstoned. + this.reclaimAlias(updated.configHost ?? updated.label) + } + return updated } removeTarget(id: string): void { + const target = this.store.getSshTarget(id) + // Why: deleting a config-managed target must record a tombstone; otherwise + // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still + // exists on disk) and the host reappears. Manual targets need no tombstone — + // sync never re-adds them. + if (target && isConfigManagedTarget(target)) { + const alias = target.configHost ?? target.label + if (alias) { + this.store.addDeletedSshConfigAlias(alias) + } + } this.store.removeSshTarget(id) } + private reclaimAlias(alias: string | undefined): void { + if (alias) { + this.store.removeDeletedSshConfigAlias(alias) + } + } + /** * Sync targets from ~/.ssh/config: insert new hosts, update existing * config-sourced ones in place (so a rotated port takes effect), never touch * manual targets. Returns the inserted and updated targets. */ - importFromSshConfig(): SshTarget[] { + importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { + // Why: the explicit Import action re-adopts every config host, so it clears + // all tombstones first. The passive on-open sync passes no flag and keeps + // deleted hosts suppressed. + if (options?.reAdopt) { + this.store.clearDeletedSshConfigAliases() + } + const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -102,6 +135,11 @@ export class SshConnectionStore { // A manual target owns this alias — never clobber it. continue } + if (deletedAliases.has(alias)) { + // The user deleted this config host — stay deleted until they re-add it + // or re-adopt config explicitly. + continue + } if (processedAliases.has(alias)) { continue } @@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } +function isConfigManagedTarget(target: SshTarget): boolean { + // Why: a target is subject to config sync (and therefore needs a tombstone on + // delete) when it is explicitly config-sourced, or a legacy import that sync + // still adopts. Manual targets are excluded — sync never re-adds them. + return ( + target.source === 'ssh-config' || + (target.source === undefined && isLegacyConfigImportTarget(target)) + ) +} + function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a6cf04e7d75..6387f62eb3e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2814,7 +2814,7 @@ export type PreloadApi = { updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: () => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ba7760a5bf9..b3dedf9e80e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3837,7 +3837,8 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'), + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:connect', args), diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 3a00df1d04e..1af4ba26f08 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -254,7 +254,10 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const handleImport = async (): Promise => { try { - const synced = (await window.api.ssh.importConfig()) as SshTarget[] + // Why: the explicit Import action re-adopts every ~/.ssh/config host, + // including ones the user previously deleted — clear tombstones so a + // deliberate re-import can bring them back. + const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[] recordFeatureInteraction('ssh') if (mountedRef.current) { if (synced.length === 0) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 24aeeac4049..7e0aef0cda9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -436,6 +436,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), workspaceSessionsByHostId: {}, sshTargets: [], + deletedSshConfigAliases: [], sshRemotePtyLeases: [], claudeLivePtySessionIds: [], migrationUnsupportedPtyEntries: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index 5b84818af79..9c074ad2b43 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3498,6 +3498,10 @@ export type PersistedState = { * pre-partition builds keep working. Optional/absent on legacy files. */ workspaceSessionsByHostId?: Partial> sshTargets: SshTarget[] + /** SSH config aliases the user explicitly deleted. Suppresses re-import of the + * matching ~/.ssh/config host on the next sync so a deleted host does not + * reappear. Cleared for an alias when the user re-adds it or re-adopts config. */ + deletedSshConfigAliases: string[] sshRemotePtyLeases: SshRemotePtyLease[] /** Daemon session ids of live local Claude launches. Seeds the Claude * live-PTY gate on startup so an early OAuth refresh cannot rotate the From 4cbf6993608a6486d856bddb4ce5ff4c247616d0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:41:32 -0700 Subject: [PATCH 007/152] fix: quote queued OMP resumes for Windows shells (#7628) --- .../src/lib/ai-vault-resume-command.test.ts | 29 +++++++++++++++++-- .../src/lib/ai-vault-resume-command.ts | 3 +- src/shared/ai-vault-resume-command.test.ts | 15 ++++++++++ src/shared/ai-vault-types.ts | 10 +++++-- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index d6be594878e..4d5db5d7237 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,9 +123,9 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) - it('resumes a local OMP session by its absolute transcript path, not its id', () => { + it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { // Regression: local rebuilds must forward session.filePath so OMP resumes by - // path (not the internal id) even when the session lives in a non-default store. + // path, and queued Windows commands must match the live tab shell. const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) const command = buildAiVaultResumeCommandForWorktree({ @@ -141,11 +141,34 @@ describe('ai vault resume command runtime', () => { }) expect(command).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" ) expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) + it('keeps cmd quoting for local OMP resume when cmd.exe is configured', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + ) + }) + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { // Regression guard: the copy path is self-contained for pasting into cmd.exe // and must stay cmd-wrapped even though the queued path now follows the shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index ce23324c02a..d71f3653fbb 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -136,7 +136,8 @@ export function buildAiVaultResumeStartupForWorktree(args: { cwd: args.session.cwd, platform, commandOverride: args.commandOverride, - codexHome + codexHome, + shell: queuedShell }) } } diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index 101237ce1c3..65fe12d4c99 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -55,6 +55,21 @@ describe('buildAiVaultResumeCommand', () => { ) }) + it('quotes queued OMP resume paths for the provided Windows shell', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + shell: 'powershell' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\Users\\Ada Lovelace\\repo'; omp --resume 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl'" + ) + }) + it('falls back to the session id when no OMP transcript path is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 4ba2c8820cb..df6abfb8349 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -105,18 +105,22 @@ export function buildAiVaultResumeCommand(args: { commandOverride?: string | null codexHome?: string | null resumeFilePath?: string | null + shell?: AgentStartupShell }): string { - const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath } = args + const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath, shell } = + args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) // Why: OMP's `--resume` accepts an absolute transcript path, which resolves // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL // home) the file was discovered under, where an id-prefix lookup scoped to // the default store would miss it. Falls back to the id if no path is known. const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId - const sessionArg = quoteShellArg(resumeTarget, platform) + const sessionArg = shell + ? quoteStartupArg(resumeTarget, shell) + : quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) - return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome }) + return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome, shell }) } export function buildAiVaultResumeShellCommand(args: { From 86f404ef53c1d3f9299668ce0c400d92cc1a5654 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:08:42 -0700 Subject: [PATCH 008/152] perf(native-chat): byte-cap the transcript read-cache to bound RSS (GB-scale leak) (#7625) --- .../native-chat/transcript-read-cache.test.ts | 76 ++++++++++++++++++- src/main/native-chat/transcript-read-cache.ts | 42 ++++++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index df5b310fcef..6f0fc39acb6 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => { import { isTextBlock } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache, - readNativeChatTranscriptCached + readNativeChatTranscriptCached, + setNativeChatTranscriptCacheMaxBytesForTests } from './transcript-read-cache' let tempRoots: string[] = [] @@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise { return filePath } +// Writes a transcript file at an explicit path whose on-disk size is ~`bytes` +// (padded via one big user message), returning the path. Read it back by passing +// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME. +async function seedBigFile(name: string, bytes: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-')) + tempRoots.push(root) + const filePath = join(root, `${name}.jsonl`) + const record = { + type: 'user', + uuid: `u-${name}`, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) } + } + await writeFile(filePath, jsonLines([record])) + return filePath +} + beforeEach(() => { clearNativeChatTranscriptCache() + setNativeChatTranscriptCacheMaxBytesForTests() readSpy.mockClear() }) @@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => { // Distinct files must not share a cached parse object. expect(c).not.toBe(a) }) + + // Why: each cached entry is a FULL unwindowed transcript parse; heavy agent + // sessions are tens of MB, so the count-only cap let 50 entries retain multiple + // GB in the one process serving desktop + every paired client. The byte budget + // evicts the oldest large entries while always keeping the most-recent. + it('evicts the oldest entry once total cached bytes exceed the budget', async () => { + // ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest. + setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024) + const fileA = await seedBigFile('big-a', 4 * 1024) + const fileB = await seedBigFile('big-b', 4 * 1024) + const fileC = await seedBigFile('big-c', 4 * 1024) + + await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1 + await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2 + await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A + expect(readSpy).toHaveBeenCalledTimes(3) + + // C is the most-recent → still cached (no re-read). + await readNativeChatTranscriptCached('claude', 'sc', fileC) + expect(readSpy).toHaveBeenCalledTimes(3) + + // A was evicted by the byte cap → this re-reads (and evicts B in turn). + await readNativeChatTranscriptCached('claude', 'sa', fileA) + expect(readSpy).toHaveBeenCalledTimes(4) + + // B is now evicted → also re-reads. + await readNativeChatTranscriptCached('claude', 'sb', fileB) + expect(readSpy).toHaveBeenCalledTimes(5) + }) + + it('keeps a single active transcript larger than the whole budget cached', async () => { + // A lone entry over budget must NOT be dropped, else every read re-parses. + setNativeChatTranscriptCacheMaxBytesForTests(1024) + const big = await seedBigFile('huge', 8 * 1024) + await readNativeChatTranscriptCached('claude', 'huge', big) + await readNativeChatTranscriptCached('claude', 'huge', big) + expect(readSpy).toHaveBeenCalledTimes(1) + }) + + it('does not evict small entries under the default budget (no regression for typical use)', async () => { + const files = await Promise.all([ + seedBigFile('s1', 512), + seedBigFile('s2', 512), + seedBigFile('s3', 512) + ]) + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + // Re-read all three: every one is still cached (byte budget never triggered). + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index b62a1a1142a..9f4df5bde87 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -21,6 +21,9 @@ type CachedTranscript = { result: ReadTranscriptResult /** mtime of the resolved file when cached; a newer mtime invalidates it. */ mtimeMs: number + /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this + * entry's parsed memory footprint, used to bound the cache by total bytes. */ + bytes: number } const cache = new Map() @@ -28,19 +31,36 @@ const cache = new Map() // Why: cap the cache so a long-lived process browsing many sessions can't grow // it unbounded. Map preserves insertion order, so evicting the first key drops // the oldest entry (a simple LRU once re-inserts bump recency; see setCached). -// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling -// shows RSS pressure now that one process serves many remote clients. const MAX_CACHE_ENTRIES = 50 +// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool +// results embed whole file contents, command output, and diffs), and each cached +// entry is the full unwindowed parse. The count cap alone let 50 such entries +// retain multiple GB in the one process that now serves desktop + every paired +// web/mobile client. Bound total cached file bytes too; we always keep the most- +// recent entry (see setCached) so an active transcript is never re-parsed on +// every read, which caps the regression to extra re-parses only past this budget. +const MAX_CACHE_BYTES = 128 * 1024 * 1024 +// Overridable only from tests so the byte-eviction path can be exercised without +// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES. +let maxCacheBytes = MAX_CACHE_BYTES function setCached(key: string, value: CachedTranscript): void { // Re-insert moves the key to the most-recent position for LRU eviction. cache.delete(key) cache.set(key, value) - while (cache.size > MAX_CACHE_ENTRIES) { + let totalBytes = 0 + for (const entry of cache.values()) { + totalBytes += entry.bytes + } + // Evict oldest until within BOTH caps, but never drop the most-recent entry + // (cache.size > 1): a single active transcript larger than the whole budget + // must stay cached or every read would re-parse the full file. + while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { const oldest = cache.keys().next().value if (oldest === undefined) { break } + totalBytes -= cache.get(oldest)?.bytes ?? 0 cache.delete(oldest) } } @@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string { return `${agent}:${filePath}` } -async function fileMtimeMs(filePath: string): Promise { +async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { try { - return (await stat(filePath)).mtimeMs + const stats = await stat(filePath) + return { mtimeMs: stats.mtimeMs, bytes: stats.size } } catch { - return Number.NaN + return { mtimeMs: Number.NaN, bytes: 0 } } } @@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const mtimeMs = await fileMtimeMs(filePath) + const { mtimeMs, bytes } = await fileStat(filePath) const cached = cache.get(key) if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. @@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached( const result = await readNativeChatTranscript(agent, sessionId, { filePath }) if (Number.isFinite(mtimeMs)) { - setCached(key, { result, mtimeMs }) + setCached(key, { result, mtimeMs, bytes }) } return result } @@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached( export function clearNativeChatTranscriptCache(): void { cache.clear() } + +/** Test-only: override the byte budget (pass no arg to restore the default). */ +export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void { + maxCacheBytes = bytes ?? MAX_CACHE_BYTES +} From a6cbd3102a6fd589b74e0b7b809f154c1197c143 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:17:35 -0700 Subject: [PATCH 009/152] fix(terminal): repaint revealed panes stuck behind xterm's paused-render gate (#7614) Co-authored-by: Orca --- .../lib/pane-manager/pane-webgl-renderer.ts | 14 +- .../terminal-render-pause-release.test.ts | 87 ++++++ .../terminal-render-pause-release.ts | 73 +++++ ...erminal-reveal-paused-render-repro.spec.ts | 268 ++++++++++++++++++ 4 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts create mode 100644 tests/e2e/terminal-reveal-paused-render-repro.spec.ts diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index d7c80e76220..e0ca805801e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -126,9 +127,16 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. pane.webglAddon?.clearTextureAtlas() - // Why: refresh even without a WebGL addon so recovery never silently - // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: on reveal xterm's IntersectionObserver can still report the pane as + // not intersecting, so a plain refresh() is swallowed by RenderService's + // paused-render gate and the cleared model never repaints (stale bottom rows + // until a drag-select forces a redraw). Force the paused render through + // first; only fall back to refresh() when the terminal was not gated. + if (!forceRepaintThroughRenderPause(pane.terminal)) { + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. + pane.terminal.refresh(0, pane.terminal.rows - 1) + } } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts new file mode 100644 index 00000000000..e3d84677424 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' + +type FakeRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: ReturnType +} + +function createTerminal(options: { + rows?: number + renderService?: FakeRenderService | null + withoutCore?: boolean +}): unknown { + const { rows = 24, renderService, withoutCore } = options + if (withoutCore) { + return { rows } + } + return { + rows, + _core: { _renderService: renderService ?? null } + } +} + +describe('forceRepaintThroughRenderPause', () => { + it('drives a synchronous full-viewport render and clears the pause latches when paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows + } + const terminal = createTerminal({ rows: 30, renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 29, true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) + + it('leaves the terminal untouched and returns false when not paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: false, + _needsFullRefresh: false, + refreshRows + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the render service internals are unavailable', () => { + expect(forceRepaintThroughRenderPause(createTerminal({ withoutCore: true }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: null }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: {} }))).toBe(false) + expect(forceRepaintThroughRenderPause(null)).toBe(false) + }) + + it('returns false without rendering when the row count is invalid', () => { + const refreshRows = vi.fn() + const terminal = createTerminal({ + rows: 0, + renderService: { _isPaused: true, refreshRows } + }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the forced render throws (disposed mid-frame)', () => { + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows: vi.fn(() => { + throw new Error('terminal disposed') + }) + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + // Latch is still cleared — the observer reasserts authority on its next + // callback, and we must not leave a half-serviced full-refresh queued. + expect(renderService._isPaused).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts new file mode 100644 index 00000000000..88b53129d87 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -0,0 +1,73 @@ +/** + * Forces a full synchronous repaint through xterm's RenderService even when its + * IntersectionObserver still reports the screen element as not intersecting. + * + * Why: on tab/worktree reveal the pane is DOM-visible but xterm's own + * observer callback can lag a frame (worse under load), leaving + * `RenderService._isPaused === true`. While paused, `refreshRows` early-returns + * and only latches `_needsFullRefresh`, so the reveal-repaint's + * `terminal.refresh()` is swallowed — the freshly-cleared render model never + * repaints and the canvas keeps compositing stale rows (classic "bottom rows + * missing until you drag-select" symptom). We can't wait for the observer, so + * we clear the latch and drive one synchronous full render ourselves; the + * observer reasserts authority naturally on its next callback. + * + * All access is behind typeof guards: an xterm upgrade that renames these + * internals degrades to a no-op (callers keep their existing refresh path), it + * never throws into a render frame. + */ + +type MaybePausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: (start: number, end: number, sync?: boolean) => void +} + +type PausableRenderService = MaybePausableRenderService & { + refreshRows: (start: number, end: number, sync?: boolean) => void +} + +type TerminalWithRenderService = { + rows?: number + _core?: { + _renderService?: MaybePausableRenderService + } +} + +function getRenderService(terminal: unknown): PausableRenderService | null { + const service = (terminal as TerminalWithRenderService | null)?._core?._renderService + return service && typeof service.refreshRows === 'function' + ? (service as PausableRenderService) + : null +} + +/** + * If xterm's renderer is paused (observer hasn't caught up to the reveal), + * clear the pause latch and force a synchronous full-viewport repaint. + * Returns true when it drove the render, false when it left the terminal + * untouched (not paused, or internals unavailable) so the caller can fall back + * to its normal `terminal.refresh()`. + */ +export function forceRepaintThroughRenderPause(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service || service._isPaused !== true) { + return false + } + + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + // Why: leave the latch as if the pending full refresh was serviced — we are + // about to service it — so the observer's next callback doesn't queue a + // redundant second full repaint. + service._isPaused = false + service._needsFullRefresh = false + try { + service.refreshRows(0, rows - 1, true) + return true + } catch { + return false + } +} diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts new file mode 100644 index 00000000000..5b280037da4 --- /dev/null +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -0,0 +1,268 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveTabId, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' + +/** + * Reproduction for the "missing bottom rows on reveal, recover on drag-select" + * bug (PR #7614). The mechanism is xterm's RenderService gating refreshRows() on + * its IntersectionObserver: while `_isPaused` is true (the observer can lag a + * frame behind a just-revealed pane, worse under load), refresh() early-returns + * and only latches `_needsFullRefresh`. The reveal-repaint's terminal.refresh() + * is then swallowed and the freshly-cleared render model never repaints. + * + * This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases + * -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real + * xterm Terminal + RenderService. It: + * 1. proves the bug: while paused, a plain refresh() renders nothing; + * 2. proves the fix: the real reveal repaint forces a full-viewport render + * through the paused gate and clears the pause latch; + * 3. confirms recovery at the pixel level. + * + * The paused state is set deterministically rather than raced, because headless + * Electron does not reliably reproduce the observer-lag timing (documented for + * this bug class). The gate we force is the exact one the field bug hits. + */ + +const BOTTOM_MARKER = 'REVEAL_PAUSED_RENDER_BOTTOM_MARKER' + +type RenderProbeResult = { + paused: boolean + renderedRanges: [number, number][] + rows: number +} + +type RevealRenderDebug = { + installProbe: () => boolean + setPaused: (paused: boolean) => boolean + dirtyModelLikeReveal: () => boolean + plainRefresh: () => void + runRealRevealRepaint: () => void + read: () => RenderProbeResult +} + +type RevealProbeWindow = Window & { + __revealRenderProbe?: RevealRenderDebug +} + +/** + * Installs an in-page probe that instruments the active pane's REAL xterm + * RenderService. Everything here runs against production objects; the only + * test-only code is the recording wrapper around `_renderRows` and the manual + * flip of `_isPaused` that stands in for the observer-lag race. + */ +async function installRevealRenderProbe(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!manager || !pane) { + throw new Error('No active pane for reveal render probe') + } + + type PausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + _renderRows?: (start: number, end: number) => void + __revealProbeRanges?: [number, number][] + __revealProbeInstalled?: boolean + } + type TerminalInternals = { + rows?: number + _core?: { _renderService?: PausableRenderService } + buffer?: { active?: { getLine?: (y: number) => unknown } } + refresh?: (start: number, end: number) => void + } + + const terminal = pane.terminal as unknown as TerminalInternals + const service = terminal._core?._renderService + if (!service || typeof service._renderRows !== 'function') { + throw new Error('Real RenderService._renderRows unavailable — cannot probe') + } + + const debug: RevealRenderDebug = { + installProbe: () => { + if (service.__revealProbeInstalled) { + service.__revealProbeRanges = [] + return true + } + service.__revealProbeRanges = [] + const original = service._renderRows!.bind(service) + service._renderRows = (start: number, end: number): void => { + service.__revealProbeRanges!.push([start, end]) + original(start, end) + } + service.__revealProbeInstalled = true + return true + }, + setPaused: (paused: boolean) => { + service._isPaused = paused + return service._isPaused === paused + }, + dirtyModelLikeReveal: () => { + // Why: reveal clears the WebGL render model so a full rebuild is forced. + // clearTextureAtlas() routes through RenderService and, crucially, also + // requests a redraw — which is exactly what the paused gate then eats. + const withAtlas = pane as unknown as { + webglAddon?: { clearTextureAtlas?: () => void } + } + withAtlas.webglAddon?.clearTextureAtlas?.() + return true + }, + plainRefresh: () => { + const rows = terminal.rows ?? 0 + terminal.refresh?.(0, Math.max(0, rows - 1)) + }, + runRealRevealRepaint: () => { + // The real production reveal path — contains the fix under test. + manager.resetWebglTextureAtlases() + }, + read: () => ({ + paused: service._isPaused === true, + renderedRanges: (service.__revealProbeRanges ?? []).slice(), + rows: terminal.rows ?? 0 + }) + } + + ;(window as RevealProbeWindow).__revealRenderProbe = debug + }, tabId) +} + +async function probeRead(page: Page): Promise { + return page.evaluate(() => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + return probe.read() + }) +} + +async function probeCall( + page: Page, + method: + | 'installProbe' + | 'setPaused' + | 'dirtyModelLikeReveal' + | 'plainRefresh' + | 'runRealRevealRepaint', + paused?: boolean +): Promise { + await page.evaluate( + ({ method, paused }) => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + if (method === 'setPaused') { + probe.setPaused(paused ?? false) + return + } + ;(probe[method] as () => void)() + }, + { method, paused } + ) +} + +async function forceWebglOn(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + if (state?.settings) { + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + } + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, tabId) +} + +test.describe('terminal reveal paused-render recovery', () => { + test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { + // Why: __store / __paneManagers live on the main Orca renderer window + // (orcaPage), not Playwright's default first page. + const page = orcaPage + await waitForSessionReady(page) + await waitForActiveTerminalManager(page) + const tabId = (await getActiveTabId(page))! + const ptyId = await waitForActivePanePtyId(page) + + await forceWebglOn(page, tabId) + + // Fill the viewport down to the bottom so a swallowed repaint is observable + // both in the render ranges and on-screen. + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 40); do echo "line $i ${BOTTOM_MARKER}_$i"; done` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_40`) + // Clear-screen + reprint so the marker sits on the real bottom rows. + await execInTerminal( + page, + ptyId, + `clear; printf '%s\\n' "$(seq 1 30)"; echo "${BOTTOM_MARKER}_FINAL"` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_FINAL`) + + const revealed = await captureStableTabScreenshot(page, tabId) + + await installRevealRenderProbe(page, tabId) + await probeCall(page, 'installProbe') + + // ---- Control: prove the bug. This sequence (clearTextureAtlas + refresh) + // is byte-for-byte the PRE-FIX resetWebglTextureAtlas on origin/main, so it + // faithfully replays the old reveal path. While paused, it renders nothing. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'plainRefresh') + // Give any (non-existent) queued render a frame to land. + await page.waitForTimeout(80) + const afterPlainRefresh = await probeRead(page) + + expect(afterPlainRefresh.paused, 'terminal is in the paused-render state').toBe(true) + expect( + afterPlainRefresh.renderedRanges, + 'BUG REPRODUCED: while paused, plain refresh() is swallowed by the gate — no render fires' + ).toHaveLength(0) + + // ---- Fix: the real reveal repaint must force a full render through the gate. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'runRealRevealRepaint') + await page.waitForTimeout(80) + const afterRealReveal = await probeRead(page) + + expect( + afterRealReveal.renderedRanges.length, + 'FIX: reveal repaint drove at least one render despite the paused gate' + ).toBeGreaterThan(0) + + const fullViewportRender = afterRealReveal.renderedRanges.some( + ([start, end]) => start === 0 && end >= afterRealReveal.rows - 1 + ) + expect( + fullViewportRender, + 'FIX: reveal repaint rendered the FULL viewport (0..rows-1), not a partial range' + ).toBe(true) + + expect( + afterRealReveal.paused, + 'FIX: pause latch is cleared so the observer can reassert authority cleanly' + ).toBe(false) + + // ---- Pixel-level confirmation: the surface still shows the correct content + // after being driven through the paused gate (no stale/blank bottom rows). + const afterFix = await captureStableTabScreenshot(page, tabId) + const diff = compareTerminalScreenshots(revealed, afterFix) + expect( + diff.matches, + `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` + ).toBe(true) + }) +}) From 98d850aebae9f9e87ab20add8fd9cb53f33101a0 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:21:58 +0200 Subject: [PATCH 010/152] fix(settings): keep full installed font list (#7581) * fix(settings): keep full installed font list * fix(settings): bound font picker rendering * Fix redundant font queries and handle partial state in worktree purge - Latch the loaded state even when the returned font list is empty, preventing font-less systems from reissuing font listing calls on every picker interaction. - Add null-tolerance for `ptyIdsByTabId` when building worktree purge state to support callers that pass partial AppState. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/system-fonts.test.ts | 57 ++++++++++++- src/main/system-fonts.ts | 45 ++++++---- .../settings/AppearanceInterfaceSection.tsx | 3 + .../settings/AppearancePane.test.tsx | 25 +++++- .../components/settings/AppearancePane.tsx | 4 + .../src/components/settings/Settings.tsx | 78 ++++++++++-------- .../settings/SettingsConstants.test.ts | 14 ++++ .../components/settings/SettingsConstants.ts | 11 ++- ...ngsFormControls.font-autocomplete.test.tsx | 82 +++++++++++++++++++ .../settings/SettingsFormControls.tsx | 31 +++++-- .../settings/TerminalAppearanceSection.tsx | 3 + .../settings-form-option-filter.test.ts | 35 ++++++++ .../settings/settings-form-option-filter.ts | 23 ++++++ src/renderer/src/store/slices/worktrees.ts | 4 +- 14 files changed, 353 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/settings/SettingsConstants.test.ts diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 3f63d8930f0..c6c5e0e42c4 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -9,16 +9,56 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) -function expectedFallbackFont(): string { - if (process.platform === 'darwin') { +function expectedFallbackFont(platform = process.platform): string { + if (platform === 'darwin') { return 'SF Mono' } - if (process.platform === 'win32') { + if (platform === 'win32') { return 'Cascadia Mono' } return 'JetBrains Mono' } +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +async function expectFontCommandTimeout( + platform: NodeJS.Platform, + timeoutMs: number +): Promise { + await withPlatform(platform, async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(timeoutMs - 1) + + expect(resolvedFonts).toBeNull() + expect(killMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform)) + expect(killMock).toHaveBeenCalledOnce() + }) +} + describe('listSystemFontFamilies', () => { afterEach(() => { vi.useRealTimers() @@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => { expect(resolvedFonts).toContain(expectedFallbackFont()) expect(killMock).toHaveBeenCalledOnce() }) + + it('uses the longer timeout for macOS profiler scans', async () => { + await expectFontCommandTimeout('darwin', 45_000) + }) + + it.each([ + ['linux' as NodeJS.Platform, 15_000], + ['win32' as NodeJS.Platform, 15_000] + ])('keeps the %s font command timeout short', async (platform, timeoutMs) => { + await expectFontCommandTimeout(platform, timeoutMs) + }) }) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index afe142562d9..15ff9c28bde 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -3,6 +3,9 @@ import { execFile } from 'node:child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 +// Why: large macOS font catalogs can make system_profiler exceed 15s even +// when it is healthy; keep the longer wait scoped to that slow command. +const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise { } function listMacFonts(): Promise { - return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then( - (output) => { - const parsed = JSON.parse(output) as { - SPFontsDataType?: { - typefaces?: { - family?: string - }[] + return execFileText( + 'system_profiler', + ['SPFontsDataType', '-json'], + 32 * 1024 * 1024, + MAC_SYSTEM_FONT_LIST_TIMEOUT_MS + ).then((output) => { + const parsed = JSON.parse(output) as { + SPFontsDataType?: { + typefaces?: { + family?: string }[] - } - - return uniqueSorted( - (parsed.SPFontsDataType ?? []).flatMap((font) => - (font.typefaces ?? []).map((typeface) => typeface.family) - ) - ) + }[] } - ) + + return uniqueSorted( + (parsed.SPFontsDataType ?? []).flatMap((font) => + (font.typefaces ?? []).map((typeface) => typeface.family) + ) + ) + }) } function listLinuxFonts(): Promise { @@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name } ) } -function execFileText(command: string, args: string[], maxBuffer: number): Promise { +function execFileText( + command: string, + args: string[], + maxBuffer: number, + timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS +): Promise { return new Promise((resolve, reject) => { let settled = false let timer: ReturnType | undefined @@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi // should fall back instead of keeping settings IPC pending forever. child.kill() reject(new Error(`Timed out listing system fonts with ${command}`)) - }, SYSTEM_FONT_LIST_TIMEOUT_MS) + }, timeoutMs) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 0f691a072dd..31afc7d6843 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -38,6 +38,7 @@ type AppearanceInterfaceSectionProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] isDesktopWindows: boolean + onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean } @@ -47,6 +48,7 @@ export function AppearanceInterfaceSection({ applyTheme, fontSuggestions, isDesktopWindows, + onRequestFontSuggestions, forceVisiblePrimary = false }: AppearanceInterfaceSectionProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) @@ -142,6 +144,7 @@ export function AppearanceInterfaceSection({ value={settings.appFontFamily} suggestions={fontSuggestions} placeholder={DEFAULT_APP_FONT_FAMILY} + onRequestSuggestions={onRequestFontSuggestions} onChange={(value) => updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) } diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 714844932f1..5fe949cea39 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -129,7 +129,10 @@ function createWarpThemesStub() { async function renderAppearancePane( settings: GlobalSettings, - updateSettings: (updates: Partial) => void = vi.fn() + updateSettings: (updates: Partial) => void = vi.fn(), + options: { + onRequestFontSuggestions?: () => void + } = {} ): Promise { const container = document.createElement('div') document.body.appendChild(container) @@ -146,6 +149,7 @@ async function renderAppearancePane( applyTheme={vi.fn()} fontSuggestions={[]} terminalFontSuggestions={[]} + onRequestFontSuggestions={options.onRequestFontSuggestions} systemPrefersDark={false} ghostty={createGhosttyStub() as never} warpThemes={createWarpThemesStub() as never} @@ -296,6 +300,25 @@ describe('AppearancePane', () => { expect(container.textContent).not.toContain('Code & Markdown') }) + it('requests installed font suggestions only after the IDE font picker is used', async () => { + mocks.state.settingsSearchQuery = '' + const requestFontSuggestions = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), vi.fn(), { + onRequestFontSuggestions: requestFontSuggestions + }) + + expect(requestFontSuggestions).not.toHaveBeenCalled() + + const input = container.querySelector('input[role="combobox"]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + + expect(requestFontSuggestions).toHaveBeenCalledOnce() + }) + it('keeps the app icon control at the bottom of the pane, after the section rows', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index f7a2dd52c13..92792621533 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -45,6 +45,7 @@ type AppearancePaneProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] terminalFontSuggestions: string[] + onRequestFontSuggestions?: () => void systemPrefersDark: boolean ghostty: UseGhosttyImportReturn warpThemes: UseWarpThemeImportReturn @@ -68,6 +69,7 @@ export function AppearancePane({ applyTheme, fontSuggestions, terminalFontSuggestions, + onRequestFontSuggestions, systemPrefersDark, ghostty, warpThemes @@ -179,6 +181,7 @@ export function AppearancePane({ updateSettings={updateSettings} applyTheme={applyTheme} fontSuggestions={fontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> @@ -205,6 +208,7 @@ export function AppearancePane({ updateSettings={updateSettings} systemPrefersDark={systemPrefersDark} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} ghostty={ghostty} warpThemes={warpThemes} forceVisiblePrimary={terminalLabelMatches} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 92c4a35b2c4..bfe82c9d783 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -23,7 +23,11 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' -import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants' +import { + SCROLLBACK_PRESETS_ROWS, + getFallbackTerminalFonts, + mergeFontSuggestions +} from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { GeneralPane } from './GeneralPane' @@ -313,7 +317,7 @@ function Settings(): React.JSX.Element { const ghostty = useGhosttyImport(updateSettings, settings) const warpThemes = useWarpThemeImport(updateSettings, settings) const [fontSuggestions, setFontSuggestions] = useState( - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()])) + mergeFontSuggestions([], getFallbackTerminalFonts()) ) const terminalFontSuggestions = useMemo( () => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY), @@ -336,7 +340,9 @@ function Settings(): React.JSX.Element { const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false) const contentScrollRef = useRef(null) const searchInputRef = useRef(null) - const terminalFontsLoadedRef = useRef(false) + const installedFontsLoadedRef = useRef(false) + const installedFontsLoadPromiseRef = useRef | null>(null) + const settingsMountedRef = useRef(true) const pendingNavSectionRef = useRef(null) const pendingScrollTargetRef = useRef(null) const pendingSubsectionScrollFrameRef = useRef(null) @@ -394,6 +400,42 @@ function Settings(): React.JSX.Element { cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef) }, []) + useEffect(() => { + // Why: React dev StrictMode replays mount effects; async font requests + // should still commit while the Settings view is actually mounted. + settingsMountedRef.current = true + return () => { + settingsMountedRef.current = false + } + }, []) + + const requestFontSuggestions = useCallback((): void => { + if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) { + return + } + + installedFontsLoadPromiseRef.current = window.api.settings + .listFonts() + .then((fonts) => { + if (!settingsMountedRef.current) { + return + } + // Latch after the first successful attempt even when empty, so a font-less + // system doesn't reissue listFonts() on every picker interaction. + installedFontsLoadedRef.current = true + if (fonts.length === 0) { + return + } + setFontSuggestions((prev) => mergeFontSuggestions(fonts, prev)) + }) + .catch(() => { + // Fall back to curated cross-platform suggestions. + }) + .finally(() => { + installedFontsLoadPromiseRef.current = null + }) + }, []) + // Pure "discard and leave?" prompt — no side effects. Why separate from the // discard helper below: the window-close guard must ask without clearing the // drafts, since a later guard/handler can still cancel the close. @@ -754,35 +796,6 @@ function Settings(): React.JSX.Element { setMountedSectionIds(neededSectionIds) } - useEffect(() => { - if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) { - return - } - if (terminalFontsLoadedRef.current) { - return - } - - let stale = false - const loadFontSuggestions = async (): Promise => { - try { - const fonts = await window.api.settings.listFonts() - if (stale || fonts.length === 0) { - return - } - terminalFontsLoadedRef.current = true - setFontSuggestions((prev) => - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320) - ) - } catch { - // Fall back to curated cross-platform suggestions. - } - } - void loadFontSuggestions() - return () => { - stale = true - } - }, [neededSectionIds]) - const neededRepoIds = useMemo( () => deriveNeededRepoIds(repos, neededSectionIds), [neededSectionIds, repos] @@ -1410,6 +1423,7 @@ function Settings(): React.JSX.Element { applyTheme={applyTheme} fontSuggestions={fontSuggestions} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={requestFontSuggestions} systemPrefersDark={systemPrefersDark} ghostty={ghostty} warpThemes={warpThemes} diff --git a/src/renderer/src/components/settings/SettingsConstants.test.ts b/src/renderer/src/components/settings/SettingsConstants.test.ts new file mode 100644 index 00000000000..46876796784 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsConstants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' +import { mergeFontSuggestions } from './SettingsConstants' + +describe('mergeFontSuggestions', () => { + it('keeps installed fonts beyond the old hydration cutoff', () => { + const systemFonts = Array.from({ length: 350 }, (_value, index) => `System Font ${index}`) + const suggestions = mergeFontSuggestions(systemFonts, ['JetBrains Mono']) + + expect(suggestions).toContain(DEFAULT_APP_FONT_FAMILY) + expect(suggestions).toContain('System Font 349') + expect(suggestions).toContain('JetBrains Mono') + }) +}) diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts index 47fb0ba281a..2c49121d31d 100644 --- a/src/renderer/src/components/settings/SettingsConstants.ts +++ b/src/renderer/src/components/settings/SettingsConstants.ts @@ -1,5 +1,5 @@ import type { OrcaHooks } from '../../../../shared/types' -import { getDefaultRepoHookSettings } from '../../../../shared/constants' +import { DEFAULT_APP_FONT_FAMILY, getDefaultRepoHookSettings } from '../../../../shared/constants' import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy' export type HookName = keyof OrcaHooks['scripts'] @@ -14,6 +14,15 @@ export function zoomLevelToPercent(level: number): number { return Math.round(100 * Math.pow(1.2, level)) } +export function mergeFontSuggestions( + systemFonts: readonly string[], + previousFonts: readonly string[] +): string[] { + // Why: picker rendering can be capped later, but the source list must keep + // every installed font searchable/selectable. + return Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...systemFonts, ...previousFonts])) +} + export function getFallbackTerminalFonts(): string[] { const nav = typeof navigator !== 'undefined' diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index 62412f2e258..4e5e3a8f235 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FontAutocomplete } from './SettingsFormControls' +import { FONT_SUGGESTION_RENDER_LIMIT } from './settings-form-option-filter' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, defaultValue: string) => defaultValue @@ -186,4 +187,85 @@ describe('FontAutocomplete', () => { expect(getOptionLabels()).toEqual(['JetBrains Mono']) }) + + it('requests installed suggestions only after the picker is used', async () => { + const requestSuggestions = vi.fn() + + function Harness(): ReactNode { + const [value, setValue] = useState('Geist') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + expect(requestSuggestions).not.toHaveBeenCalled() + + await act(async () => { + getInput().focus() + }) + + expect(requestSuggestions).toHaveBeenCalledOnce() + }) + + it('searches suggestions past the previous 320 font cutoff', async () => { + const suggestions = [ + ...Array.from({ length: 350 }, (_value, index) => `System Font ${index}`), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('System Font 0') + return + } + + await act(async () => { + root.render() + }) + + const input = getInput() + + await act(async () => { + input.focus() + }) + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels()).not.toContain('Zed Mono') + + await typeIntoInput(input, 'Zed') + + expect(getOptionLabels()).toEqual(['Zed Mono']) + }) + + it('renders the selected late-list font even when the full list is capped', async () => { + const suggestions = [ + ...Array.from({ length: FONT_SUGGESTION_RENDER_LIMIT + 30 }, (_value, index) => { + return `System Font ${index}` + }), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('Zed Mono') + return + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels().at(-1)).toBe('Zed Mono') + }) }) diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a8b5bbdef05..146e0000f62 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -13,6 +13,7 @@ import { MAX_THEME_RESULTS } from './SettingsConstants' import { filterFontSuggestions, filterTerminalThemeOptions, + getRenderedFontSuggestions, isSettingsFormOptionQueryTooLarge } from './settings-form-option-filter' import { cn } from '@/lib/utils' @@ -286,6 +287,7 @@ type FontAutocompleteProps = { suggestions: string[] onChange: (value: string) => void placeholder?: string + onRequestSuggestions?: () => void /** Fires with whichever option the user is currently highlighting in the * dropdown (via mouse hover or keyboard arrow), or null when nothing is * highlighted / the dropdown is closed. Lets a consumer show a live @@ -588,6 +590,7 @@ export function FontAutocomplete({ suggestions, onChange, placeholder = 'SF Mono', + onRequestSuggestions, onPreviewFontFamily }: FontAutocompleteProps): React.JSX.Element { const [query, setQuery] = useState(value) @@ -619,8 +622,15 @@ export function FontAutocomplete({ } } + const requestSuggestions = useCallback((): void => { + onRequestSuggestions?.() + }, [onRequestSuggestions]) + const handleOpenChange = (nextOpen: boolean): void => { setOpen(nextOpen) + if (nextOpen) { + requestSuggestions() + } if (!nextOpen) { setIsFilteringQuery(false) } @@ -636,6 +646,10 @@ export function FontAutocomplete({ // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions + const renderedSuggestions = useMemo( + () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), + [visibleSuggestions, highlightedIndex] + ) // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter @@ -699,12 +713,14 @@ export function FontAutocomplete({ value={query} onChange={(e) => { const next = e.target.value + requestSuggestions() setQuery(next) setIsFilteringQuery(true) onChange(next) setOpen(true) }} onFocus={() => { + requestSuggestions() setIsFilteringQuery(false) setOpen(true) }} @@ -795,6 +811,7 @@ export function FontAutocomplete({ setIsFilteringQuery(false) } if (nextOpen) { + requestSuggestions() focusInput() } }} @@ -831,29 +848,29 @@ export function FontAutocomplete({ }} > 8 ? 'h-64' : undefined} + className={renderedSuggestions.length > 8 ? 'h-64' : undefined} style={popoverAvailableHeightStyle} viewportProps={{ style: popoverAvailableHeightStyle }} >
{visibleSuggestions.length > 0 ? ( - visibleSuggestions.map((font, index) => ( + renderedSuggestions.map(({ font, sourceIndex }) => ( - )} + placeholder={defaultCmd} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {cmdOverride && ( + + )} +
) } @@ -330,45 +339,47 @@ function AgentDefaultArgsInput({ } return ( -
- +
+ {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} - setArgsDraft(e.target.value)} - onBlur={commitArgs} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitArgs() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setArgsDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={ - defaultArgs || - translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') - } - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {argsOverride !== defaultArgs && ( - - )} + placeholder={ + defaultArgs || + translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') + } + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {argsOverride !== defaultArgs && ( + + )} +
) } @@ -394,11 +405,11 @@ function AgentDefaultEnvInput({ } return ( -
+
+ + {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} +
- - {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} - { @@ -476,7 +487,8 @@ function AgentRow({ onSetEnabled, onSaveOverride, onSaveArgs, - onSaveEnv + onSaveEnv, + sessionSourceHome }: AgentRowProps): React.JSX.Element { const envSummary = stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) @@ -494,15 +506,6 @@ function AgentRow({
{label} - {isDetected ? ( - - {translate('auto.components.settings.AgentsPane.c8794e622e', 'Detected')} - - ) : ( - - {translate('auto.components.settings.AgentsPane.df123171d1', 'Not installed')} - - )} {!isEnabled && ( {translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')} @@ -523,7 +526,7 @@ function AgentRow({
-
+
-
- {isDetected && ( - - )} -
-
)} -

+ {sessionSourceHome && ( +

+ +
+ )} +

{translate( 'auto.components.settings.AgentsPane.f9f127d664', 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' @@ -902,6 +893,11 @@ export function AgentsPane({ onSaveOverride={(v) => saveOverride(agent.id, v)} onSaveArgs={(v) => saveAgentArgs(agent.id, v)} onSaveEnv={(v) => saveAgentEnv(agent.id, v)} + sessionSourceHome={ + agent.id === 'codex' + ? buildCodexSessionSourceHomeControl(settings, updateSettings) + : undefined + } /> ))}

diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx new file mode 100644 index 00000000000..731398c3d0b --- /dev/null +++ b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/types' +import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' + +function settingsWith( + overrides: Partial> +): Pick { + return { + codexSessionSourceHome: overrides.codexSessionSourceHome, + localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' } + } +} + +describe('buildCodexSessionSourceHomeControl', () => { + it('reads and writes the host override on the host runtime', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ codexSessionSourceHome: { host: '/custom/codex' } }), + updateSettings + ) + + expect(control.runtimeLabel).toBe('~/.codex') + expect(control.value).toBe('/custom/codex') + + control.onSave('/new/codex') + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { host: '/new/codex' } + }) + }) + + it('scopes to the selected WSL distro', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }), + updateSettings + ) + + expect(control.value).toBe('/home/me/.codex') + control.onSave('/home/me/.config/codex') + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + }) + + it('reads a stored WSL value when the reported distro casing differs', () => { + // Stored as "Ubuntu"; the runtime reports "ubuntu". The launch-time resolver + // matches case-insensitively, so the UI must surface the active value too. + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + vi.fn() + ) + + expect(control.value).toBe('/home/me/.codex') + }) + + it('updates the existing WSL key in place instead of creating a duplicate casing', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + updateSettings + ) + + control.onSave('/home/me/.config/codex') + // No stray "ubuntu" key alongside "Ubuntu". + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + }) + + it('clears the matching WSL key regardless of casing when reset', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + updateSettings + ) + + control.onSave('') + // Removing the only key leaves no dangling wsl object. + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: undefined } + }) + }) + + it('falls back to the host control when a WSL scope has no selected distro', () => { + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { host: '/custom/codex' }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: null } + }), + vi.fn() + ) + + expect(control.runtimeLabel).toBe('~/.codex') + expect(control.value).toBe('/custom/codex') + }) +}) diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx new file mode 100644 index 00000000000..6a49a7b6042 --- /dev/null +++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react' +import { Info } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { translate } from '@/i18n/i18n' + +export type AgentSessionSourceHomeControl = { + runtimeLabel: string + value: string + onSave: (value: string) => void +} + +type UpdateSettings = (updates: Partial) => void | Promise + +/** + * Builds the Codex session-history source control scoped to the runtime the + * Agents pane is showing (host or the selected WSL distro), so it mirrors how + * detected agents are scoped. History-only: it never touches auth/config. + */ +// Why: the launch-time resolver matches distro keys case-insensitively, so the +// UI must resolve to the SAME stored key. Otherwise a casing mismatch would show +// an empty override for an active value and, on edit, create a duplicate key. +function findWslSourceHomeKey( + wsl: Record | undefined, + distro: string +): string | undefined { + const normalized = distro.trim().toLowerCase() + return Object.keys(wsl ?? {}).find((key) => key.trim().toLowerCase() === normalized) +} + +export function buildCodexSessionSourceHomeControl( + settings: Pick, + updateSettings: UpdateSettings +): AgentSessionSourceHomeControl { + const runtimeScope = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) + const sourceHome = settings.codexSessionSourceHome + // Why: a WSL scope with no selected distro can't target a per-distro history + // home, so fall back to the host control rather than a null distro key. + const wslDistro = runtimeScope.kind === 'wsl' ? runtimeScope.distro?.trim() : undefined + if (wslDistro) { + const existingKey = findWslSourceHomeKey(sourceHome?.wsl, wslDistro) + return { + runtimeLabel: `${wslDistro}: ~/.codex`, + value: (existingKey ? sourceHome?.wsl?.[existingKey] : undefined) ?? '', + // Save under the existing key when one matches (case-insensitively), so + // editing updates it in place instead of adding a differently-cased key. + onSave: (value: string) => + saveCodexSessionSourceHome(settings, updateSettings, { + runtime: 'wsl', + distro: existingKey ?? wslDistro, + value + }) + } + } + return { + runtimeLabel: '~/.codex', + value: sourceHome?.host ?? '', + onSave: (value: string) => + saveCodexSessionSourceHome(settings, updateSettings, { runtime: 'host', value }) + } +} + +function saveCodexSessionSourceHome( + settings: Pick, + updateSettings: UpdateSettings, + args: { runtime: 'host'; value: string } | { runtime: 'wsl'; distro: string; value: string } +): void { + const current = settings.codexSessionSourceHome ?? {} + const trimmed = args.value.trim() + if (args.runtime === 'host') { + updateSettings({ codexSessionSourceHome: { ...current, host: trimmed || undefined } }) + return + } + const nextWsl = { ...current.wsl } + // Why: reuse an existing case-insensitive match so we never leave a stale + // duplicate key behind when the caller passes a differently-cased distro. + const targetKey = findWslSourceHomeKey(nextWsl, args.distro) ?? args.distro + if (trimmed) { + nextWsl[targetKey] = trimmed + } else { + delete nextWsl[targetKey] + } + updateSettings({ + codexSessionSourceHome: { + ...current, + wsl: Object.keys(nextWsl).length > 0 ? nextWsl : undefined + } + }) +} + +export function AgentSessionSourceHomeInput({ + runtimeLabel, + value, + onSave +}: AgentSessionSourceHomeControl): React.JSX.Element { + const [draft, setDraft] = useState(value) + + const commit = (): void => { + onSave(draft.trim()) + } + + return ( +
+ + {translate( + 'auto.components.settings.AgentsPane.codexSessionSource', + 'Codex home to import from' + )} + + + + + + {translate( + 'auto.components.settings.AgentsPane.codexSessionSourceTooltip', + 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.' + )} + + + +
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit() + e.currentTarget.blur() + } + if (e.key === 'Escape') { + setDraft(value) + e.currentTarget.blur() + } + }} + placeholder={runtimeLabel} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {value.trim() && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d4efa1e146..7ea7146ec6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4758,7 +4758,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "3f1bdf3cb4": "Environment text is too large to parse safely.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Next icon", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 66a2b10f23b..cc51dd41b99 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4758,7 +4758,10 @@ "agentPermissionsDescription": "Elige si Orca lanza agents con menos prompts de permisos o con checks manuales.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura." + "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2b76dba34a1..a70953da324 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", - "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。" + "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 646b74a42f7..511f3b1e3c8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "Orca가 agents를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "수동", - "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다." + "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 66de9505f78..d46dfc98f40 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动智能体,还是手动检查。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", - "3f1bdf3cb4": "环境文本过长,无法安全解析。" + "3f1bdf3cb4": "环境文本过长,无法安全解析。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "下一个图标", diff --git a/src/shared/types.ts b/src/shared/types.ts index 9c074ad2b43..92715f7861a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2784,6 +2784,16 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** Why: Orca bridges Codex session history from the user's real Codex home into + * its managed home so /resume finds it, but defaults to ~/.codex. Users who run + * Codex with a custom CODEX_HOME can point history discovery at that folder here. + * History-only: this does not change which account/config/hooks Orca uses. */ + codexSessionSourceHome?: { + /** Absolute host path; empty/undefined falls back to ~/.codex. */ + host?: string + /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ + wsl?: Record + } /** Per-agent default CLI arguments appended after the binary/path and before prompts. */ agentDefaultArgs?: Partial> /** Per-agent launch environment defaults used when yolo mode is exposed as env. */ From 46a67cb2eb1d71ab4dfd3a94224cb533793e44d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:02:59 -0700 Subject: [PATCH 022/152] perf(source-control): cache PR conflict-summary derivation and throttle base fetch (#7606) Co-authored-by: Orca --- src/main/github/client.test.ts | 16 +- src/main/github/conflict-summary-cache.ts | 150 ++++++++++ src/main/github/conflict-summary.test.ts | 331 ++++++++++++++++++++++ src/main/github/conflict-summary.ts | 126 ++++++-- 4 files changed, 597 insertions(+), 26 deletions(-) create mode 100644 src/main/github/conflict-summary-cache.ts create mode 100644 src/main/github/conflict-summary.test.ts diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2138ba0aad0..5363cb56c60 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -122,7 +122,7 @@ import { _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' -import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary' +import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' describe('checkOrcaStarred', () => { @@ -194,7 +194,7 @@ describe('getPRForBranch', () => { _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetTrackedUpstreamBranchCacheForTests() - __resetPRConflictSummaryGitCapabilityCacheForTests() + __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() }) @@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => { baseRefOid: 'base-oid', headRefOid: 'head-oid' } + // Why a second head OID: identical inputs now hit the summary result + // cache outright; a pushed head re-derives and must still skip the + // unsupported --merge-base retry via the capability cache. + const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } + const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) @@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetPRConflictSummaryCachesForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts new file mode 100644 index 00000000000..99a2715ce6d --- /dev/null +++ b/src/main/github/conflict-summary-cache.ts @@ -0,0 +1,150 @@ +import type { PRConflictSummary } from '../../shared/types' + +// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR +// (10s mergeability-pending, 2.5s manual-pending) previously each ran a +// network fetch; one fetch per base branch per minute matches the 60s minimum +// background refresh cadence, so the tracked base tip is never staler than the +// PR data around it. Manual refresh intentionally shares the window: GitHub's +// own mergeability recompute is async too, and the card self-corrects within +// a minute. +export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000 + +// Why bounded: the main process is long-lived; cap the maps so repos and PRs +// that stop refreshing can't accumulate entries forever. +const BASE_OID_CACHE_MAX = 64 +const SUMMARY_CACHE_MAX = 128 + +export type FreshBaseTipResolution = + | { kind: 'resolved'; oid: string } + | { kind: 'fallback-unresolved' } + +type CachedBaseTipResolution = { + oid: string | null + resolvedAt: number +} + +type CachedSummary = { + value: PRConflictSummary | undefined + // Why: a successful summary is a pure function of two immutable commit OIDs + // and never goes stale; a failed derivation depends on which objects exist + // locally, which the next fetch window can change, so it carries an expiry. + staleAt: number | null +} + +const baseOidCache = new Map() +const summaryCache = new Map() +const inFlightBaseOidResolves = new Map>() +const inFlightSummaryDerivations = new Map>() + +// Why: WSL distros have their own git binary, filesystem view, and remote +// access, so cached state must never leak across the host/distro boundary. +export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string { + return wslDistro ? `wsl:${wslDistro}` : 'local:host' +} + +// Why JSON: repo paths and git ref names may contain any printable joiner +// character (git allows `|` in branch names), so a delimiter-joined key could +// alias distinct identities onto one cache entry. +export function buildConflictSummaryCacheKey(...parts: string[]): string { + return JSON.stringify(parts) +} + +export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null { + const entry = baseOidCache.get(baseKey) + if (!entry) { + return null + } + if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) { + baseOidCache.delete(baseKey) + return null + } + return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' } +} + +export function storeResolvedBaseTip(baseKey: string, oid: string): void { + setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX) +} + +export function rememberUnresolvedBaseTip(baseKey: string): void { + setBoundedMapEntry( + baseOidCache, + baseKey, + { oid: null, resolvedAt: Date.now() }, + BASE_OID_CACHE_MAX + ) +} + +export function readCachedSummary(summaryKey: string): CachedSummary | null { + const entry = summaryCache.get(summaryKey) + if (!entry) { + return null + } + if (entry.staleAt !== null && Date.now() >= entry.staleAt) { + summaryCache.delete(summaryKey) + return null + } + return entry +} + +export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void { + setBoundedMapEntry( + summaryCache, + summaryKey, + { + value, + staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null + }, + SUMMARY_CACHE_MAX + ) +} + +export function dedupeBaseOidResolve( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightBaseOidResolves, key, factory) +} + +export function dedupeSummaryDerivation( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightSummaryDerivations, key, factory) +} + +function dedupeInFlight( + map: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = map.get(key) + if (existing) { + return existing + } + const promise = factory().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + +export function __resetPRConflictSummaryDerivationCachesForTests(): void { + baseOidCache.clear() + summaryCache.clear() + inFlightBaseOidResolves.clear() + inFlightSummaryDerivations.clear() +} diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts new file mode 100644 index 00000000000..a9b5e9aee9a --- /dev/null +++ b/src/main/github/conflict-summary.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why these tests exist: the conflict-summary derivation used to re-run a +// network `git fetch` plus a four-subprocess chain on every PR refresh tick +// (measured ~490 full derivations in 2.1h on one machine). They pin the +// regression contract by counting subprocess spawns: unchanged inputs must +// cost zero subprocesses, and the base fetch must run at most once per +// throttle window. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache' +import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary' + +type GitResult = { stdout: string } +type GitHandler = (argv: string[]) => Promise + +const defaultHandlers: Record = { + fetch: async () => ({ stdout: '' }), + 'rev-parse': async () => ({ stdout: 'base-tip-1\n' }), + 'merge-base': async () => ({ stdout: 'merge-base-1\n' }), + 'rev-list': async () => ({ stdout: '3\n' }), + 'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' }) +} + +function mockGitDispatch(overrides: Record = {}): void { + gitExecFileAsyncMock.mockImplementation((argv: string[]) => { + const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]] + if (!handler) { + return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`)) + } + return handler(argv) + }) +} + +function spawnCount(command?: string): number { + const calls = gitExecFileAsyncMock.mock.calls + if (!command) { + return calls.length + } + return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length +} + +const expectedSummary = { + baseRef: 'main', + baseCommit: 'base-ti', + commitsBehind: 3, + files: ['src/conflict.ts'] +} + +function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) { + return getPRConflictSummary( + '/repo-root', + 'main', + 'github-base-oid', + headRefOid, + wslDistro ? { wslDistro } : {} + ) +} + +describe('getPRConflictSummary caching', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_750_000_000_000) + gitExecFileAsyncMock.mockReset() + __resetPRConflictSummaryCachesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => { + mockGitDispatch() + + const first = await deriveSummary() + expect(first).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + + const second = await deriveSummary() + const third = await deriveSummary() + expect(second).toEqual(expectedSummary) + expect(third).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => { + mockGitDispatch() + + await deriveSummary('head-oid-1') + const afterPush = await deriveSummary('head-oid-2') + + expect(afterPush).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => { + mockGitDispatch() + + await deriveSummary() + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + const afterExpiry = await deriveSummary() + + expect(afterExpiry).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('rev-parse')).toBe(2) + // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits. + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('re-derives after the throttle window when the base tip moved', async () => { + mockGitDispatch() + await deriveSummary() + + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch({ + 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }), + 'rev-list': async () => ({ stdout: '5\n' }) + }) + const afterBaseMove = await deriveSummary() + + expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 }) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const firstCall = deriveSummary() + const secondCall = deriveSummary() + releaseFetch?.({ stdout: '' }) + const [first, second] = await Promise.all([firstCall, secondCall]) + + expect(first).toEqual(expectedSummary) + expect(second).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('dedupes overlapping derivations after resolving the same live base tip', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const backgroundRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'older-github-base-oid', + 'head-oid-1', + {} + ) + const manualRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'newer-github-base-oid', + 'head-oid-1', + {} + ) + releaseFetch?.({ stdout: '' }) + const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh]) + + expect(backgroundSummary).toEqual(expectedSummary) + expect(manualSummary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('shares one base fetch across concurrent PRs on the same base branch', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const prA = deriveSummary('head-oid-a') + const prB = deriveSummary('head-oid-b') + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual(expectedSummary) + expect(summaryB).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }), + 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')), + 'rev-list': async (argv) => ({ + stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n' + }), + 'merge-tree': async (argv) => ({ + stdout: argv.includes('bbbb2222-base') + ? 'tree-oid\u0000src/b.ts\u0000' + : 'tree-oid\u0000src/a.ts\u0000' + }) + }) + + const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {}) + const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {}) + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual({ + baseRef: 'main', + baseCommit: 'aaaa111', + commitsBehind: 4, + files: ['src/a.ts'] + }) + expect(summaryB).toEqual({ + baseRef: 'main', + baseCommit: 'bbbb222', + commitsBehind: 7, + files: ['src/b.ts'] + }) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('falls back to the local remote-tracking ref when fetch fails', async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + + // Why: a failed fetch attempt must also be throttled — offline machines + // were the worst case, paying the 10s fetch timeout on every tick. + const repeat = await deriveSummary() + expect(repeat).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + }) + + it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')), + 'rev-parse': () => Promise.reject(new Error('unknown revision')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' }) + expect( + gitExecFileAsyncMock.mock.calls.some( + ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid') + ) + ).toBe(true) + }) + + it('keeps WSL-distro derivations isolated from host derivations', async () => { + mockGitDispatch() + + await deriveSummary() + await deriveSummary('head-oid-1', 'Ubuntu') + + expect(spawnCount('fetch')).toBe(2) + expect( + gitExecFileAsyncMock.mock.calls.filter( + ([argv, options]) => + argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu' + ) + ).toHaveLength(1) + }) + + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { + mockGitDispatch() + + // Why: these two calls alias under naive pipe-joined keys — the segment + // boundary shifts between repoPath and baseRefName. + await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) + await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) + + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('caches failed derivations only until the throttle window expires', async () => { + mockGitDispatch({ + 'merge-base': () => Promise.reject(new Error('bad object')) + }) + + await expect(deriveSummary()).resolves.toBeUndefined() + const failureSpawns = spawnCount() + + // Within the window the failure is negative-cached: zero subprocesses. + await expect(deriveSummary()).resolves.toBeUndefined() + expect(spawnCount()).toBe(failureSpawns) + + // After expiry the derivation retries (a fetch may have brought objects in). + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch() + await expect(deriveSummary()).resolves.toEqual(expectedSummary) + expect(spawnCount('merge-base')).toBe(2) + }) +}) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce0c4b361b7..9644555048a 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,5 +1,17 @@ import type { PRConflictSummary } from '../../shared/types' import { gitExecFileAsync } from '../git/runner' +import { + __resetPRConflictSummaryDerivationCachesForTests, + buildConflictSummaryCacheKey, + dedupeBaseOidResolve, + dedupeSummaryDerivation, + getConflictSummaryGitRuntimeKey, + readCachedSummary, + readFreshBaseTipResolution, + rememberUnresolvedBaseTip, + storeResolvedBaseTip, + storeCachedSummary +} from './conflict-summary-cache' type LocalGitExecOptions = { wslDistro?: string @@ -7,8 +19,9 @@ type LocalGitExecOptions = { const mergeTreeMergeBaseUnsupportedRuntimes = new Set() -export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void { +export function __resetPRConflictSummaryCachesForTests(): void { mergeTreeMergeBaseUnsupportedRuntimes.clear() + __resetPRConflictSummaryDerivationCachesForTests() } export async function getPRConflictSummary( @@ -18,45 +31,122 @@ export async function getPRConflictSummary( headRefOid: string, localGitOptions: LocalGitExecOptions = {} ): Promise { - try { - // Why: the renderer only needs a read-only merge-conflict snapshot. We - // derive it from local git state so the PR card can show GitHub-style - // detail without spending additional gh API calls on every refresh. We use - // GitHub's head OID directly because the registered repo path may not have - // a matching local branch name for the PR head. For the base side, prefer a - // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, - // which compares against the latest base branch tip rather than the PR's - // older pinned baseRefOid snapshot. - const latestBaseOid = await resolveLatestBaseOid( + // Why: the renderer only needs a read-only merge-conflict snapshot. We + // derive it from local git state so the PR card can show GitHub-style + // detail without spending additional gh API calls on every refresh. We use + // GitHub's head OID directly because the registered repo path may not have + // a matching local branch name for the PR head. For the base side, prefer a + // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, + // which compares against the latest base branch tip rather than the PR's + // older pinned baseRefOid snapshot. + const latestBaseOid = await resolveLatestBaseOidThrottled( + repoPath, + baseRefName, + baseRefOid, + localGitOptions + ) + // Why: the summary is a pure function of the two commit OIDs, so a key hit + // can skip the whole merge-base/rev-list/merge-tree subprocess chain. + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const summaryKey = buildConflictSummaryCacheKey( + runtimeKey, + repoPath, + baseRefName, + headRefOid, + latestBaseOid + ) + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + // Why: different GitHub reads can report different pinned baseRefOid values + // while still resolving to the same live base tip; dedupe the expensive + // local derivation on the actual summary identity. + return dedupeSummaryDerivation(summaryKey, () => + derivePRConflictSummary( repoPath, baseRefName, - baseRefOid, + headRefOid, + latestBaseOid, + summaryKey, localGitOptions ) + ) +} + +async function derivePRConflictSummary( + repoPath: string, + baseRefName: string, + headRefOid: string, + latestBaseOid: string, + summaryKey: string, + localGitOptions: LocalGitExecOptions +): Promise { + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + try { const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions) const [commitsBehind, files] = await Promise.all([ countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions), loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions) ]) - return { + const summary = { baseRef: baseRefName, baseCommit: latestBaseOid.slice(0, 7), commitsBehind, files, ...(files.length === 0 ? { localMergeState: 'clean' as const } : {}) } + storeCachedSummary(summaryKey, summary) + return summary } catch { + storeCachedSummary(summaryKey, undefined) return undefined } } -async function resolveLatestBaseOid( +async function resolveLatestBaseOidThrottled( repoPath: string, baseRefName: string, fallbackBaseOid: string, localGitOptions: LocalGitExecOptions ): Promise { + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName) + const cachedResolution = readFreshBaseTipResolution(baseKey) + if (cachedResolution) { + return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid + } + return dedupeBaseOidResolve(baseKey, async () => { + // Why re-check inside the dedupe slot: a sibling caller may have finished + // resolving between our cache read and this factory starting. + const freshResolution = readFreshBaseTipResolution(baseKey) + if (freshResolution) { + return freshResolution + } + const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions) + if (oid) { + storeResolvedBaseTip(baseKey, oid) + return { kind: 'resolved', oid } + } + // Why cache the unresolved probe, not the caller fallback: the fetch + // attempt is branch-wide expensive work, but GitHub's baseRefOid is + // PR-specific and must not leak to sibling PRs on the same base branch. + rememberUnresolvedBaseTip(baseKey) + return { kind: 'fallback-unresolved' } + }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid)) +} + +async function resolveLatestBaseOid( + repoPath: string, + baseRefName: string, + localGitOptions: LocalGitExecOptions +): Promise { const remoteName = 'origin' try { @@ -88,7 +178,7 @@ async function resolveLatestBaseOid( } } - return fallbackBaseOid + return null } async function resolveMergeBase( @@ -123,7 +213,7 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilityKey = getMergeTreeCapabilityKey(localGitOptions) + const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) const modernArgs = [ 'merge-tree', '--write-tree', @@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string { - return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host' -} - function parseMergeTreeNameOnlyOutput(stdout: string): string[] { const entries = stdout.split('\0').filter(Boolean) if (entries.length === 0) { From 544bca5202fc43de3ad94b3c4ae6b94b063e85f6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:03:39 -0700 Subject: [PATCH 023/152] fix: terminal IME candidate selection and text commit on Linux (#7634) * fix terminal IME candidate selection and text commit on Linux Sogou Pinyin and fcitx on Linux failed in Orca's terminal because bare 229 keydowns were swallowed, and empty composition updates prematurely deactivated tracking. This led to dropped Chinese text or leaked Space/digit candidate-selection keys reaching the PTY. - Allow bare 229 keydowns to bypass suppression on Linux so xterm can diff and commit text. - Prevent empty compositionupdate events from prematurely deactivating the composition tracker. - Suppress and preventDefault candidate-selection keys (Space and digits) during active composition and a brief post-composition window. - Add comprehensive unit tests and an Electron CDP-driven E2E repro. * fix: register IME gate command as direct spec-file invocation The reliability-gate checker rejects --grep title selectors and requires every evidenceRun command to match a gate command. Drop the --grep from the e2e gate command and its evidence run, and remove the stale 3-file evidence run superseded by the full 7-file run. Co-authored-by: Orca * Guard overlapping and post-composition Linux IME candidate keys - Track pending candidate key releases in a Map instead of a single slot to support overlapping selector key events without stranding. - Apply the candidate selection guard to post-composition key releases that arrive after compositionend, preventing digits/Space from leaking into the PTY. - Restrict the Linux/Sogou candidate selection guard to Linux to prevent interference on macOS and Windows. - Exclude Shift+Space from candidate selection key checks. * Guard held-key IME candidate repeats and scope policy to desktop Linux - Keep auto-repeat keydowns for a candidate key suppressed past the 250ms guard window until its corresponding keyup event is received. - Clear stale pending releases on fresh non-repeat keydowns to avoid guarding the wrong key events. - Exclude Android and ChromeOS user agents from desktop Linux-specific IME candidate key suppression behaviors. - Ensure the composition tracker is activated unconditionally on compositionupdate events. * Clean up IME reference and extract shared test event fixture - Remove the obsolete Linux Sogou Pinyin IME reference document. - Extract the fully-defaulted XtermBypassEvent helper into a shared fixture file to keep the policy test suites in sync. - Add a test verifying that Shift+Space (fcitx full-/half-width toggle) is not suppressed as an IME candidate key. --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 84 ++++-- ...al-ime-candidate-key-release-guard.test.ts | 171 ++++++++++++ ...erminal-ime-candidate-key-release-guard.ts | 94 +++++++ .../terminal-ime-composition-tracker.test.ts | 167 ++++++++++++ .../terminal-ime-composition-tracker.ts | 75 +++++- .../use-terminal-pane-lifecycle.ts | 52 +++- .../xterm-bypass-event-fixture.ts | 17 ++ .../xterm-bypass-policy-non-mac.test.ts | 255 +++++++++++++++--- .../terminal-pane/xterm-bypass-policy.test.ts | 50 ++-- .../terminal-pane/xterm-bypass-policy.ts | 60 ++++- .../e2e/chinese-ime-chat-input-repro.spec.ts | 194 +++++++++++++ 11 files changed, 1132 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bdabd2a7b79..1975f41c7f9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-03", + "updatedAt": "2026-07-07", "policy": { "maturityLevels": [ "experimental", @@ -1273,7 +1273,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1281,14 +1281,20 @@ "https://github.com/stablyai/orca/pull/6999" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ { @@ -1309,34 +1315,80 @@ "assertions": [ "paste/runtime forwarding avoids duplicate terminal payloads" ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "assertions": [ + "empty Sogou-style compositionupdate keeps composition active", + "stale composition state expires so editing keys cannot stay suppressed indefinitely", + "post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "assertions": [ + "suppressed candidate keydown arms only the matching keypress/keyup release guard", + "held-key repeat keydowns stay guarded until their keyup, even past expiry", + "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "assertions": [ + "standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed", + "candidate Space/digit selectors are suppressed only while candidate guards are active" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "assertions": [ + "macOS standalone Process key behavior and composition-owned key suppression stay intact" + ] + }, + { + "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "assertions": [ + "Sogou-style Space candidate selection submits only the committed Chinese character", + "Sogou-style digit candidate selection submits only the committed Chinese phrase", + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "result": "passed", - "durationSeconds": 0.9, - "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 0.7, + "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 72.0, + "summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME." } ], "runtimeBudget": { - "p95Seconds": 30, - "scope": "renderer unit plus platform soak" + "p95Seconds": 90, + "scope": "renderer unit plus focused Electron/CDP IME repro" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion." + "evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { - "required": false, - "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work." + "required": true, + "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", @@ -1344,7 +1396,7 @@ "Pair with Windows ConPTY keyboard reset for Windows standard-key behavior." ], "knownGaps": [ - "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.", + "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts new file mode 100644 index 00000000000..228ff9ddca6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + isTerminalImeCandidateSelectionKeyEvent, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' +import { event } from './xterm-bypass-event-fixture' + +describe('terminal IME candidate key release guard', () => { + it('recognizes only unmodified Space and digit candidate selectors', () => { + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: '2' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: 'a' }))).toBe(false) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', ctrlKey: true }))).toBe(false) + // Shift+Space is fcitx's full-/half-width toggle, not a candidate selector. + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) + }) + + it('arms a pending release guard from a suppressed candidate keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect(releases.get('2')).toBe(10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + }) + + it('does not arm from keyup, non-candidate, or Shift-modified keys', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: 'a' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ', shiftKey: true }), 10) + expect(releases.size).toBe(0) + }) + + it('guards the matching keypress and keyup after insertText clears the tracker', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + }) + + it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect(shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20)).toBe( + false + ) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2', ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('guards held-key repeat keydowns while the release is pending, even past expiry', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + // Linux auto-repeat delay (~500ms) outlives the 250ms guard window; the + // held selector's repeats must stay suppressed until its keyup. + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 500 + ) + ).toBe(true) + }) + + it('does not guard repeat keydowns without a pending release for that key', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '3', repeat: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true, ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + }) + + it('clears a stale pending release on a fresh non-repeat keydown but not on repeats', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2', repeat: true })) + expect(releases.has('2')).toBe(true) + // A new physical press means the prior keyup was missed (focus change + // mid-hold); its stale entry must not guard the new press's repeats. + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('guards a pending matching keyup even if modifier state changed after keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2', shiftKey: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('clears on the matching keyup', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keypress', key: '2' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('tracks overlapping candidate keys independently so each keyup clears its own guard', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + // Second candidate keydown arrives before the first key's keyup. + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '2' }), releases, 20) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + ).toBe(true) + // The first key's keyup no longer strands the second key's pending release. + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('3')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts new file mode 100644 index 00000000000..210d8de22d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -0,0 +1,94 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' + +// Why: candidate keys can overlap (a second selector keydown before the first +// keyup), so pending releases are tracked per key rather than in one slot; +// otherwise an overwritten key's keyup can no longer clear its own guard. +export type TerminalImePendingCandidateKeyReleases = Map + +// Why: Sogou/fcitx can deliver candidate-selection keys as plain key events +// (#7543: digit selection inserts only the digit). While the IME owns them, +// they must not reach xterm's encoders or Chromium's default text insertion. +const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ + ' ', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9' +]) + +function isTerminalImeCandidateSelectionKey(key: string): boolean { + return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) +} + +export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent): boolean { + // Modified chords are never candidate selectors: Ctrl/Meta/Alt are IME + // toggles, and Shift+Space is fcitx's full-/half-width width toggle. + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) { + return false + } + return isTerminalImeCandidateSelectionKey(event.key) +} + +export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { + return new Map() +} + +export function armTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent, + now: number +): void { + if (event.type !== 'keydown' || !isTerminalImeCandidateSelectionKeyEvent(event)) { + return + } + releases.set(event.key, now + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) +} + +export function shouldApplyTerminalImePendingCandidateKeyRelease( + event: XtermBypassEvent, + releases: TerminalImePendingCandidateKeyReleases, + now: number +): boolean { + if (event.type === 'keydown') { + // Why: key auto-repeat outlives the 250ms window (Linux repeat delay is + // ~500ms), so a held selector's repeats stay owned by its pending release; + // a fresh press means the prior keyup was missed and the entry is stale. + return ( + event.repeat === true && + isTerminalImeCandidateSelectionKeyEvent(event) && + releases.has(event.key) + ) + } + if (event.type === 'keyup') { + // Why: keyup modifier flags can reflect keys pressed after the original + // selector keydown, so release suppression matches only the pending key. + return isTerminalImeCandidateSelectionKey(event.key) && releases.has(event.key) + } + if (!isTerminalImeCandidateSelectionKeyEvent(event)) { + return false + } + const expiresAt = releases.get(event.key) + if (expiresAt === undefined) { + return false + } + return now <= expiresAt +} + +export function clearTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent +): void { + // Why: a non-repeat keydown is a new physical press, so any surviving entry + // for that key lost its keyup (focus change mid-hold) and must not guard the + // new press's repeats. Callers clear before arming. + if (event.type === 'keyup' || (event.type === 'keydown' && event.repeat !== true)) { + releases.delete(event.key) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts new file mode 100644 index 00000000000..1e363adcec4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + installTerminalImeCompositionTracker, + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS, + TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS, + type TerminalImeCompositionTracker +} from './terminal-ime-composition-tracker' + +type TrackerHarness = { + tracker: TerminalImeCompositionTracker + element: HTMLElement + advance: (ms: number) => void + composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void + input: (inputType: string) => void + blur: () => void +} + +function installTracker(): TrackerHarness { + let now = 0 + const element = document.createElement('div') + const tracker = installTerminalImeCompositionTracker(element, { now: () => now }) + return { + tracker, + element, + advance: (ms) => { + now += ms + }, + composition: (type, data) => { + const event = new CompositionEvent(type, { bubbles: true }) + Object.defineProperty(event, 'data', { value: data }) + element.dispatchEvent(event) + }, + input: (inputType) => { + element.dispatchEvent(new InputEvent('input', { inputType, bubbles: true })) + }, + blur: () => { + element.dispatchEvent(new Event('blur', { bubbles: true })) + } + } +} + +describe('installTerminalImeCompositionTracker', () => { + it('activates on compositionstart', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('keeps composition active through Sogou-style empty compositionupdate', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', 'ni') + harness.composition('compositionupdate', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on compositionend', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('still clears on non-composition input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertText') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('does not clear on insertCompositionText input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertCompositionText') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on blur', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.blur() + expect(harness.tracker.isActive()).toBe(false) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + describe('candidate key guard', () => { + it('is active while a composition is live', () => { + const harness = installTracker() + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + harness.composition('compositionstart', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('stays active through empty compositionupdate events', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('expires after prolonged composition-event silence so stale IME state cannot keep terminal keys dead', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + expect(harness.tracker.isActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + expect(harness.tracker.isActive()).toBe(false) + }) + + it('refreshes the expiry window on composition activity', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + harness.composition('compositionupdate', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('does not arm the post-composition window without Sogou-style empty updates', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('absorbs the Sogou-style committing key after compositionend, then expires', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('drops the post-composition window once ordinary typing resumes', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + harness.input('insertText') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + }) + + it('handles a missing terminal element', () => { + const tracker = installTerminalImeCompositionTracker(null) + expect(tracker.isActive()).toBe(false) + expect(tracker.isCandidateKeyGuardActive()).toBe(false) + expect(() => tracker.dispose()).not.toThrow() + }) + + it('stops tracking after dispose', () => { + const harness = installTracker() + harness.tracker.dispose() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index 0e3c9e4bbfb..73f6d83a0b0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -2,47 +2,112 @@ import type { IDisposable } from '@xterm/xterm' export type TerminalImeCompositionTracker = IDisposable & { isActive: () => boolean + /** True while candidate-selection keys (Space/digits) should be treated as + * IME-owned: during a live composition, and briefly after compositionend to + * absorb the committing key's trailing press/release. */ + isCandidateKeyGuardActive: () => boolean } +// Why: suppressed candidate keys are preventDefault-ed and fire no input +// event, so a stale tracker (missed compositionend) has no natural unstick +// path. Expire the guard so Space/digits cannot stay dead indefinitely. +export const TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS = 10_000 +// Why: Sogou/fcitx can deliver the committing Space/digit as plain keydown and +// keyup after compositionend; a narrow window absorbs those trailing events +// without making the keys globally unavailable after IME use. +export const TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS = 250 + export function installTerminalImeCompositionTracker( - terminalElement: HTMLElement | null | undefined + terminalElement: HTMLElement | null | undefined, + options?: { now?: () => number } ): TerminalImeCompositionTracker { + const now = options?.now ?? ((): number => Date.now()) let active = false + let lastCompositionEventAt: number | null = null + let compositionEndedAt: number | null = null + let sawEmptyCompositionUpdate = false + + const isActiveAt = (at: number): boolean => + active && + (lastCompositionEventAt === null || + at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + + const isCandidateKeyGuardActive = (): boolean => { + const at = now() + if (isActiveAt(at)) { + return true + } + return ( + compositionEndedAt !== null && + at - compositionEndedAt <= TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + ) + } + if (!terminalElement) { return { isActive: () => active, + isCandidateKeyGuardActive, dispose: () => undefined } } const markActive = (): void => { active = true + lastCompositionEventAt = now() + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const updateComposition = (event: Event): void => { - active = !(event instanceof CompositionEvent) || event.data !== '' + lastCompositionEventAt = now() + // Why: Sogou/fcitx can emit empty compositionupdate data while its + // candidate popup is still open — empty data must not deactivate. + // compositionend, non-composition input, and blur own deactivation. + if (!(event instanceof CompositionEvent)) { + return + } + if (event.data === '') { + sawEmptyCompositionUpdate = true + return + } + active = true + } + const handleCompositionEnd = (): void => { + active = false + // Why: only Sogou/fcitx-style empty updates prove a trailing plain + // Space/digit is likely IME-owned; broad post-end guards drop real typing. + compositionEndedAt = sawEmptyCompositionUpdate ? now() : null + sawEmptyCompositionUpdate = false } const handleInput = (event: Event): void => { if (event instanceof InputEvent && event.inputType === 'insertCompositionText') { return } active = false + // Why: real non-composition input means ordinary typing resumed; keeping + // the post-end window would swallow a legitimate Space/digit. + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const markInactive = (): void => { active = false + lastCompositionEventAt = null + compositionEndedAt = null + sawEmptyCompositionUpdate = false } terminalElement.addEventListener('compositionstart', markActive, true) terminalElement.addEventListener('compositionupdate', updateComposition, true) - terminalElement.addEventListener('compositionend', markInactive, true) + terminalElement.addEventListener('compositionend', handleCompositionEnd, true) terminalElement.addEventListener('input', handleInput, true) terminalElement.addEventListener('blur', markInactive, true) return { - isActive: () => active, + isActive: () => isActiveAt(now()), + isCandidateKeyGuardActive, dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) - terminalElement.removeEventListener('compositionend', markInactive, true) + terminalElement.removeEventListener('compositionend', handleCompositionEnd, true) terminalElement.removeEventListener('input', handleInput, true) terminalElement.removeEventListener('blur', markInactive, true) } 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 4baa9944ee2..e342778eed5 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 @@ -64,6 +64,12 @@ import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' import { DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, getMacNativeTextInputSourceTracker @@ -72,6 +78,7 @@ import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-tex import { shouldBypassXtermKeyboardEvent, shouldHandleTerminalInterruptKeyboardEvent, + shouldPreventDefaultTerminalImeCandidateKey, shouldSuppressTerminalImeKeyboardEvent, shouldSuppressTerminalInterruptKeyup, shouldSuppressTerminalModifierKeyboardEvent, @@ -842,7 +849,16 @@ export function useTerminalPaneLifecycle({ // encoder runs, letting the browser and Electron paths fire normally. // See xterm-bypass-policy.ts for the rule derivation. let pendingTerminalInterruptKeyup = false + const pendingTerminalImeCandidateKeyReleases = + createTerminalImePendingCandidateKeyReleases() const isMac = navigator.userAgent.includes('Mac') + // Why: Android/ChromeOS UAs also contain "Linux"; keep the Sogou/fcitx + // candidate-key policy scoped to desktop Linux so paired web clients on + // those platforms keep their previous IME behavior. + const isLinux = + !isMac && + navigator.userAgent.includes('Linux') && + !/Android|CrOS/.test(navigator.userAgent) const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker) @@ -864,14 +880,38 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { - if ( - shouldSuppressTerminalImeKeyboardEvent(e, { - compositionActive: imeCompositionTracker.isActive(), - isMac - }) - ) { + const now = Date.now() + const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) + const imeKeyboardOptions = { + compositionActive: imeCompositionTracker.isActive(), + candidateKeyGuardActive: + imeCompositionTracker.isCandidateKeyGuardActive() || + pendingCandidateReleaseGuardActive, + pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, + isMac, + isLinux + } + if (shouldSuppressTerminalImeKeyboardEvent(e, imeKeyboardOptions)) { + // Why: clear before arm — a fresh keydown drops any stale pending + // release for its key before the new press arms its own. + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) + if (shouldPreventDefaultTerminalImeCandidateKey(e, imeKeyboardOptions)) { + // Why: without preventDefault the suppressed candidate keydown + // still fires a keypress and mutates the helper textarea. + e.preventDefault() + armTerminalImePendingCandidateKeyRelease( + pendingTerminalImeCandidateKeyReleases, + e, + now + ) + } return false } + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { pendingTerminalInterruptKeyup = false return false diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts new file mode 100644 index 00000000000..a4548ab5b99 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts @@ -0,0 +1,17 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' + +// Shared test fixture: builds a fully-defaulted XtermBypassEvent so the bypass +// and IME candidate-guard suites all stay in sync when the event shape changes. +export function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index b5b0d40d3cd..7e5d55e4bff 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { const withSel = { isMac: false, hasSelection: true } @@ -161,60 +148,256 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { - const idle = { isMac: false, compositionActive: false } - const composing = { isMac: false, compositionActive: true } + const windowsIdle = { + isMac: false, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxIdle = { + isMac: false, + isLinux: true, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxComposing = { ...linuxIdle, compositionActive: true, candidateKeyGuardActive: true } + // Post-compositionend guard: the tracker is already inactive but the + // committing key's trailing press/release must still be absorbed. + const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } + const windowsComposing = { ...windowsIdle, compositionActive: true, candidateKeyGuardActive: true } it('suppresses keyboard events while Chromium reports active IME composition', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: 'Backspace', code: 'Backspace', isComposing: true }), - idle - ) - ).toBe(true) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace', isComposing: true }), + options + ) + ).toBe(true) + } }) it('suppresses Windows IME Process keys', () => { + // Why: Windows preedit can hit the textarea before compositionstart; + // letting the 229 keydown through would flush it via xterm's textarea diff. expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Process', code: 'KeyN', keyCode: 229 }), - idle + windowsIdle ) ).toBe(true) }) - it('does not suppress ordinary Backspace outside IME composition', () => { + it('lets standalone Linux 229 keydowns reach xterm so its CompositionHelper can diff text', () => { + // Why: Sogou/fcitx candidate commits can ride a bare 229 keydown outside a + // composition session; xterm must see it to schedule its textarea diff. expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), idle) + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxIdle + ) ).toBe(false) }) + it('suppresses Linux 229 keydowns while the composition tracker is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxComposing + ) + ).toBe(true) + }) + + it('suppresses 229 / Process keyups so kitty release reporting cannot leak', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: 'Process', code: 'KeyN', keyCode: 229 }), + options + ) + ).toBe(true) + } + }) + + it('does not suppress ordinary Backspace outside IME composition', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace' }), + options + ) + ).toBe(false) + } + }) + it('suppresses IME-owned editing keys while composition is active', () => { expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Backspace', code: 'Backspace' }), - composing + linuxComposing ) ).toBe(true) expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'ArrowDown', code: 'ArrowDown' }), - composing + linuxComposing ) ).toBe(true) }) it('does not suppress ordinary text keys solely because composition is active', () => { expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), composing) + shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), linuxComposing) ).toBe(false) }) it('does not suppress keypress events because they carry committed text', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ type: 'keypress', key: '中', code: '', isComposing: true }), - idle - ) - ).toBe(false) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '中', code: '', isComposing: true }), + options + ) + ).toBe(false) + } + }) + + describe('candidate-selection keys (Sogou Space/digit commit)', () => { + it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => { + for (const options of [linuxComposing, linuxPostCompositionGuard]) { + for (const key of [' ', '0', '2', '9']) { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options) + ).toBe(true) + } + } + }) + + it('suppresses the follow-on candidate keypress so _keyPress cannot forward the selector', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: ' ', code: 'Space' }), + linuxPostCompositionGuard + ) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + linuxComposing + ) + ).toBe(true) + }) + + it('leaves Space and digits alone once the guard has expired', () => { + for (const type of ['keydown', 'keyup', 'keypress']) { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ type, key: ' ', code: 'Space' }), linuxIdle) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: '2', code: 'Digit2' }), + linuxIdle + ) + ).toBe(false) + } + }) + + it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', ctrlKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not treat Shift+Space (fcitx full-/half-width toggle) as a candidate key', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', shiftKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('leaves ordinary letters unsuppressed while the guard is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'a', code: 'KeyA' }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not apply the Linux/Sogou candidate guard to Windows', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space' }), + windowsComposing + ) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + windowsComposing + ) + ).toBe(false) + }) + + it('suppresses a pending Linux candidate release even if modifier state changed after keydown', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: '2', code: 'Digit2', shiftKey: true }), + { + ...linuxIdle, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: true + } + ) + ).toBe(true) + }) + }) + + describe('shouldPreventDefaultTerminalImeCandidateKey', () => { + it('prevents the default on candidate keydowns while the guard is active', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(true) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: '2', code: 'Digit2' }), + linuxPostCompositionGuard + ) + ).toBe(true) + }) + + it('does not prevent the default for keyups, expired guards, or non-candidate keys', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ type: 'keyup', key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: 'a', code: 'KeyA' }), linuxComposing) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + windowsComposing + ) + ).toBe(false) + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 6d0a70ada5b..dc69ea2578d 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — macOS', () => { const opts = { isMac: true, hasSelection: true } @@ -164,8 +151,20 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { - const idle = { isMac: true, compositionActive: false } - const composing = { isMac: true, compositionActive: true } + const idle = { + isMac: true, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const composing = { + isMac: true, + isLinux: false, + compositionActive: true, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: false + } it('suppresses keyboard events while Chromium reports active IME composition', () => { expect( @@ -238,4 +237,19 @@ describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { ) ).toBe(false) }) + + it('does not apply the Linux/Sogou candidate guard to macOS', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + composing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 57a29c36a4c..34e1240ef84 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -18,6 +19,7 @@ export type XtermBypassEvent = { code?: string keyCode?: number isComposing?: boolean + repeat?: boolean defaultPrevented?: boolean metaKey: boolean ctrlKey: boolean @@ -35,9 +37,18 @@ export type XtermBypassOptions = { export type XtermImeKeyboardOptions = { compositionActive: boolean + /** True while Linux/Sogou candidate-selection keys (Space/digits) are + * IME-owned: live composition plus a short post-compositionend window. */ + candidateKeyGuardActive: boolean + /** True when the pending-release guard already matched this specific event. */ + pendingCandidateKeyReleaseActive: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean + // Required Linux/Windows split: Linux passes standalone 229 keydowns like + // macOS; the Windows-only suppression guards its preedit-diff race (preedit + // can hit the textarea before compositionstart and be flushed by the diff). + isLinux: boolean } export const TERMINAL_INTERRUPT_INPUT = '\x03' @@ -74,18 +85,55 @@ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, options: XtermImeKeyboardOptions ): boolean { + const { + compositionActive, + candidateKeyGuardActive, + pendingCandidateKeyReleaseActive, + isMac, + isLinux + } = options + const suppressCandidateKey = + isLinux && + (pendingCandidateKeyReleaseActive || + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) + if (event.type === 'keypress') { + // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, + // so its native keypress still fires and _keyPress would forward the + // literal Space/digit to the PTY. + return suppressCandidateKey + } if (!isXtermHandledKeyEvent(event.type)) { return false } - const { compositionActive, isMac } = options // Why: IMEs own Process-key / composing keystrokes — letting xterm translate - // them corrupts committed CJK text. Bare macOS keydown 229 is exempt: it must - // reach xterm's CompositionHelper or the first key after an input-source - // switch is swallowed. + // them corrupts committed CJK text. Bare macOS/Linux keydown 229 is exempt: + // it must reach xterm's CompositionHelper so it can schedule its textarea + // diff (macOS: first key after an input-source switch; Linux: Sogou/fcitx + // candidate commits outside a composition session). Windows keeps full + // suppression until verified against its preedit-diff race. + const passesStandalone229Keydown = isMac || isLinux return ( event.isComposing === true || - (event.keyCode === 229 && (event.type !== 'keydown' || compositionActive || !isMac)) || - (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) + (event.keyCode === 229 && + (event.type !== 'keydown' || compositionActive || !passesStandalone229Keydown)) || + (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) || + suppressCandidateKey + ) +} + +export function shouldPreventDefaultTerminalImeCandidateKey( + event: XtermBypassEvent, + options: XtermImeKeyboardOptions +): boolean { + // Why: returning false from attachCustomKeyEventHandler does not + // preventDefault — the candidate keydown would still fire a keypress and + // write into the helper textarea, where a later 229 diff could flush the + // leaked selector to the PTY. + return ( + event.type === 'keydown' && + options.isLinux && + options.candidateKeyGuardActive && + isTerminalImeCandidateSelectionKeyEvent(event) ) } diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index edb2fede1a3..02dbb03c112 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -79,6 +79,8 @@ function stripTerminalControls(value: string): string { const CODEX_READY_RE = /Ask Codex|OpenAI/i const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i +const LINUX_IME_POLICY_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36' function terminalImeHarnessScript(runId: string): string { return ` @@ -229,6 +231,9 @@ async function installImeEventProbe(page: Page): Promise { ]) { textarea.addEventListener(type, record, true) } + // Why: the Linux/Sogou post-composition guard lives on the terminal + // element; record terminal-targeted compositionend without doubling key logs. + pane.terminal.element?.addEventListener('compositionend', record, true) }) } @@ -239,6 +244,19 @@ async function readImeEventLog(page: Page): Promise { }) } +async function reloadWithLinuxImePolicy(page: Page): Promise { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { + get: () => userAgent, + configurable: true + }) + }, LINUX_IME_POLICY_USER_AGENT) + // Why: the Sogou repro validates Linux-gated terminal IME policy on macOS + // runners; reload before the terminal pane mounts so lifecycle code sees it. + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +} + async function readPromptState(page: Page): Promise { const content = stripTerminalControls(await getTerminalContent(page, 20_000)) const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)] @@ -320,6 +338,64 @@ async function dispatchImeProcessKey(session: CDPSession, code: string): Promise }) } +async function dispatchCandidateSelectionKey( + session: CDPSession, + candidate: { key: string; code: string; keyCode: number }, + commitBetweenKeys?: () => Promise +): Promise { + // Why: #7543 proves Sogou can forward candidate selectors as plain key + // events, not Process/229. `text` is set so keyDown produces the natural + // keypress — rawKeyDown generates none and would prove nothing about leaks. + await session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode, + text: candidate.key, + unmodifiedText: candidate.key + }) + // Why: committing between keyDown and keyUp keeps xterm's _keyDownSeen set, + // matching the trace shape where an insertText commit is actually at risk. + await commitBetweenKeys?.() + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode + }) +} + +async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { + // Why: Sogou/fcitx emits empty compositionupdate data while its candidate + // popup is still open (#6765); Orca's tracker must not flip inactive on it. + await page.evaluate(() => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + active.dispatchEvent(new CompositionEvent('compositionupdate', { data: '', bubbles: true })) + }) +} + +async function dispatchSogouPostCompositionEnd(page: Page, data: string): Promise { + // Why: some Sogou/fcitx traces deliver the plain selector key after + // compositionend; target the terminal element so Orca's tracker sees the end + // without making xterm finalize a synthetic preedit string. + await page.evaluate((data) => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + const terminalElement = active.closest('.xterm') + if (!(terminalElement instanceof HTMLElement)) { + throw new Error('xterm terminal element was not found') + } + terminalElement.dispatchEvent(new CompositionEvent('compositionend', { data, bubbles: false })) + }, data) +} + async function composeAndCommitChineseText( session: CDPSession, page: Page, @@ -473,6 +549,124 @@ test.describe('Chinese IME terminal chat input repro', () => { } }) + test('keeps Sogou-style candidate selection keys out of the PTY while committing Chinese text', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await reloadWithLinuxImePolicy(orcaPage) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-sogou-ime-harness-${runId}.cjs`) + let session: CDPSession | null = null + let harnessStarted = false + + try { + // Why: create the session/harness inside the try so a mid-setup throw + // still hits finally and removes the harness script. + writeFileSync(scriptPath, terminalImeHarnessScript(runId)) + session = await orcaPage.context().newCDPSession(orcaPage) + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + harnessStarted = true + await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000) + await focusActiveTerminalInput(orcaPage) + await installImeEventProbe(orcaPage) + + // Space selects the first candidate. Sogou keeps emitting empty + // compositionupdate frames while the popup is open, and the plain Space + // press arrives around the commit rather than as a Process/229 key. + await setImeComposition(session, 'n') + await orcaPage.waitForTimeout(80) + await setImeComposition(session, 'ni') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: ' ', code: 'Space', keyCode: 32 }, () => + commitImeText(session, '你') + ) + await waitForLivePrompt(orcaPage, '你') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-space-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'space-selected candidate did not submit the committed Chinese character' + }) + .toBe('你') + + // Digit selects a non-first candidate for a word/phrase commit — the + // #7543 shape where only the number used to reach the TUI. + await setImeComposition(session, 'nihao') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: '2', code: 'Digit2', keyCode: 50 }, () => + commitImeText(session, '你好') + ) + await waitForLivePrompt(orcaPage, '你好') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'digit-selected candidate did not submit the committed Chinese phrase' + }) + .toBe('你好') + + // Post-composition traces can deliver the selector after compositionend; + // the short post-end guard must still keep that plain digit out of the PTY. + const postCompositionLogStart = (await readImeEventLog(orcaPage)).length + await setImeComposition(session, 'zaijian') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchSogouPostCompositionEnd(orcaPage, '再见') + await dispatchCandidateSelectionKey(session, { key: '3', code: 'Digit3', keyCode: 51 }, () => + commitImeText(session, '再见') + ) + await waitForLivePrompt(orcaPage, '再见') + const postCompositionLog = await readImeEventLog(orcaPage) + const postCompositionEndIndex = postCompositionLog.findIndex( + (entry, index) => + index >= postCompositionLogStart && entry.type === 'compositionend' && entry.data === '再见' + ) + const postCompositionSelectorIndex = postCompositionLog.findIndex( + (entry, index) => + index > postCompositionEndIndex && entry.type === 'keydown' && entry.key === '3' + ) + expect( + postCompositionEndIndex, + 'Sogou post-composition repro must dispatch compositionend before the plain selector' + ).toBeGreaterThanOrEqual(postCompositionLogStart) + expect( + postCompositionSelectorIndex, + 'plain digit selector must arrive after compositionend in the post-composition repro' + ).toBeGreaterThan(postCompositionEndIndex) + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-post-composition-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'post-composition digit-selected candidate did not submit cleanly' + }) + .toBe('再见') + + const promptState = await readPromptState(orcaPage) + expect( + promptState?.submitted, + 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' + ).toEqual(['你', '你好', '再见']) + } finally { + await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) + await session?.detach().catch(() => undefined) + if (harnessStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('keeps composed Chinese text stable in the real Codex TUI input @real-codex-ime', async ({ orcaPage }, testInfo) => { From eed00581686290480dd7d445bf3708644cb45ff0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:04:32 +0000 Subject: [PATCH 024/152] release: v1.4.127-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a793e5c51af..d34b0b68891 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.126", + "version": "1.4.127-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From af59239c1a58a2ee494c0022f06a61765ca6fb1b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:01:51 -0700 Subject: [PATCH 025/152] test(e2e): relax hidden PTY timer drift gate --- .../e2e/artificial-opencode-hidden-pressure-scenario.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 0e67a67bb6d..90fa83b8741 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -79,6 +79,9 @@ type HiddenPressureAckGate = { // a loaded OSS runner the drain-plus-poll overhead was seen at ~3.2s, so keep a // ceiling with headroom that still catches an order-of-magnitude regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 4_000 +// Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key +// latency catch the same isolated CI starvation spike; median remains strict. +const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 export function pressureOutputScript(runId: string): string { return ` @@ -215,11 +218,7 @@ export async function runHiddenRealPtyPressureScenario< // detector — the original regression (input freezing for seconds) shows up in // the median too. Aligns with ssh-docker-relay-perf's 2s worst-key tolerance. expect(measurement.worstLatencyMs).toBeLessThan(3_000) - // Why: maxTimerDriftMs is a single-worst-tick metric that spikes on a loaded - // OSS runner (seen at 155ms under 8MB of in-flight backpressure). Median above - // is the real responsiveness guard; align the spike tolerance with the sibling - // terminal-load suite's MAX_TIMER_DRIFT_MS. - expect(measurement.maxTimerDriftMs).toBeLessThan(250) + expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS) await deps.releaseTerminalAckGate(orcaPage) const restoreLatencyMs = await measureHiddenOutputRestoreLatency( From cf7fb4f7bb370aa0a45314a7378313d76f53f4ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:28:26 +0000 Subject: [PATCH 026/152] 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 3f124029e89..d8d797085e2 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.7m + + downloads: 3.8m @@ -15,7 +15,7 @@ downloads downloads - 3.7m - 3.7m + 3.8m + 3.8m From 2dad1f04f375237a532b080b6e819a094652b339 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:57:48 +0000 Subject: [PATCH 027/152] 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 d8d797085e2..4afaca9293c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.8m + + downloads: 3.9m @@ -15,7 +15,7 @@ downloads downloads - 3.8m - 3.8m + 3.9m + 3.9m From 0f7658d77c06ada39bf109b298ecbe11f4f4f1ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:12:00 -0700 Subject: [PATCH 028/152] Make center split divider more visible (#7701) * fix: address review findings * Improve split divider contrast and refactor tab split context menus - Increase contrast of `--tab-group-split-divider` colors in light and dark modes to achieve at least 3:1 contrast against `--card`. - Refactor `TerminalTabSplitMenuSection` to use the shared `TabWorkspaceLayoutMenuSection` for moving tabs between splits. - Clarify terminal-specific split action labels in the context menu. - Remove redundant icon margins in `EditorFileTabContextMenu`. --- src/renderer/src/assets/main.css | 56 ++++++++++++++ .../components/tab-bar/BrowserTab.test.tsx | 15 ++++ .../src/components/tab-bar/BrowserTab.tsx | 23 +++--- .../components/tab-bar/EditorFileTab.test.tsx | 18 +++++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 24 ++++++ .../tab-bar/EditorFileTabContextMenu.tsx | 32 ++++++-- .../SortableTab.rename-shortcut.test.tsx | 24 ++++++ .../tab-bar/SortableTabContextMenu.test.tsx | 16 +++- .../tab-bar/SortableTabContextMenu.tsx | 61 +++++++--------- .../tab-bar/TabWorkspaceLayoutMenuSection.tsx | 24 +++++- .../tab-bar/TerminalTabSplitMenuSection.tsx | 73 +++++++++++++++++++ .../components/tab-group/TabGroupPanel.tsx | 16 +++- .../tab-group/TabGroupSplitLayout.tsx | 32 +++++++- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + 18 files changed, 368 insertions(+), 61 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f028b641384..b77b58a5c1a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -101,6 +101,8 @@ --color-status-success: var(--status-success); --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); + --color-tab-group-split-divider: var(--tab-group-split-divider); + --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong); --color-git-graph-ref: var(--git-graph-ref); --color-git-graph-remote-ref: var(--git-graph-remote-ref); --color-git-graph-base-ref: var(--git-graph-base-ref); @@ -202,6 +204,11 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #f59e0b; + /* Why: tab-group splits use a dedicated divider — `--border` is too faint on + card/editor surfaces when panes are side-by-side or stacked. Default must + meet >=3:1 against `--card`, not only the hover/drag strong state. */ + --tab-group-split-divider: #868690; + --tab-group-split-divider-strong: #71717a; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -285,6 +292,9 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #fbbf24; + /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */ + --tab-group-split-divider: #71717a; + --tab-group-split-divider-strong: #a1a1aa; } .linear-priority-bars { @@ -618,6 +628,52 @@ overflow: hidden; } +/* Tab-group split resize handle — wide hit area, visible center line. */ +.tab-group-split-resize-handle { + flex-shrink: 0; + position: relative; + background: transparent; +} + +.tab-group-split-resize-handle.is-vertical { + width: 6px; + cursor: col-resize; +} + +.tab-group-split-resize-handle.is-horizontal { + height: 6px; + cursor: row-resize; +} + +.tab-group-split-resize-handle::after { + content: ''; + position: absolute; + border-radius: 1px; + background: var(--tab-group-split-divider); + transition: background 100ms ease; +} + +.tab-group-split-resize-handle.is-vertical::after { + top: 0; + bottom: 0; + left: 50%; + width: 3px; + transform: translateX(-50%); +} + +.tab-group-split-resize-handle.is-horizontal::after { + left: 0; + right: 0; + top: 50%; + height: 3px; + transform: translateY(-50%); +} + +.tab-group-split-resize-handle:hover::after, +.tab-group-split-resize-handle.is-dragging::after { + background: var(--tab-group-split-divider-strong); +} + /* ── Titlebar ────────────────────────────────────────── */ /* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index f4dc863f8e8..2dfd84d87e5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -42,6 +42,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -60,6 +72,9 @@ vi.mock('lucide-react', () => ({ PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Rows2: function Rows2(props: Record) { return { type: 'Rows2', props } }, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 0dfb97a9af6..45bc82e7989 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react' +import { Globe, X, ExternalLink, Copy, Pin, PinOff, PanelRightClose } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -279,37 +279,36 @@ export default function BrowserTab({ sideOffset={0} align="start" > + - + {translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')} - {isPinned ? ( - - ) : ( - - )} + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab') : translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} void window.api.shell.openUrl(openInBrowserUrl)} disabled={!isHttpUrl} > - + {translate('auto.components.tab.bar.BrowserTab.6e0bc8f3a8', 'Open In Browser')} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 59a1c18bcee..66071a7da1d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -58,6 +58,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -70,6 +82,12 @@ vi.mock('lucide-react', () => ({ Eye: function Eye(props: Record) { return { type: 'Eye', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, GitCompareArrows: function GitCompareArrows(props: Record) { return { type: 'GitCompareArrows', props } }, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 8167bdbda5c..09257498698 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -36,12 +36,33 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Copy: function Copy(props: Record) { return { type: 'Copy', props } }, ExternalLink: function ExternalLink(props: Record) { return { type: 'ExternalLink', props } }, + Eye: function Eye(props: Record) { + return { type: 'Eye', props } + }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -56,6 +77,9 @@ vi.mock('lucide-react', () => ({ }, PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } + }, + X: function X(props: Record) { + return { type: 'X', props } } })) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 184c05d2d97..bbf9a75d74a 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -1,4 +1,14 @@ -import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react' +import { + Copy, + ExternalLink, + Eye, + ListX, + PanelRightClose, + Pencil, + Pin, + PinOff, + X +} from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -107,6 +117,11 @@ export function EditorFileTabContextMenu({ event.preventDefault() }} > + { @@ -115,24 +130,25 @@ export function EditorFileTabContextMenu({ onOpenRenameInput() }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} {renameShortcut ? {renameShortcut} : null} - {isPinned ? : } + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab') : translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} {closeShortcut ? {closeShortcut} : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' @@ -142,6 +158,7 @@ export function EditorFileTabContextMenu({ ) : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.e5ff31ccaf', 'Close Tabs To The Right' @@ -165,6 +182,7 @@ export function EditorFileTabContextMenu({ ) }} > + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.bfd5797ef4', 'Open Markdown Preview' @@ -178,7 +196,7 @@ export function EditorFileTabContextMenu({ void window.api.ui.writeClipboardText(file.filePath) }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.5b85754786', 'Copy Path')} - + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.52ce4f4605', 'Copy Relative Path' @@ -208,7 +226,7 @@ export function EditorFileTabContextMenu({ window.api.shell.openPath(file.filePath) }} > - + {revealLabel} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index bb4e757d1c8..d183891b6de 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -72,6 +72,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -84,6 +96,15 @@ vi.mock('lucide-react', () => ({ PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + MessageSquare: function MessageSquare(props: Record) { + return { type: 'MessageSquare', props } + }, + Pencil: function Pencil(props: Record) { + return { type: 'Pencil', props } + }, Pin: function Pin(props: Record) { return { type: 'Pin', props } }, @@ -95,6 +116,9 @@ vi.mock('lucide-react', () => ({ }, X: function X(props: Record) { return { type: 'X', props } + }, + SquareTerminal: function SquareTerminal(props: Record) { + return { type: 'SquareTerminal', props } } })) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 71b067fa898..6fb14821294 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -50,10 +50,20 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: () => null, + ArrowLeft: () => null, + ArrowRight: () => null, + ArrowUp: () => null, + Columns2: () => null, + ListX: () => null, + MessageSquare: () => null, PanelBottomClose: () => null, PanelRightClose: () => null, + Pencil: () => null, Pin: () => null, - PinOff: () => null + PinOff: () => null, + SquareTerminal: () => null, + X: () => null })) vi.mock('@/i18n/i18n', () => ({ @@ -218,6 +228,7 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal') act(() => getButton(container, 'Right').click()) expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', { @@ -226,7 +237,7 @@ describe('SortableTabContextMenu', () => { }) }) - it('hides split actions for a single-tab group', () => { + it('hides move-tab split actions for a single-tab group', () => { storeMock.state = { ...storeMock.state, groupsByWorktree: { @@ -243,5 +254,6 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).not.toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal right') }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index f93afd46d89..469c4f7159a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,10 +1,12 @@ import { MessageSquare, - PanelBottomClose, PanelRightClose, Pin, PinOff, - SquareTerminal + Pencil, + SquareTerminal, + X, + ListX } from 'lucide-react' import { DropdownMenu, @@ -18,8 +20,7 @@ import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' -import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' -import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' +import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' const TAB_COLORS = [ { @@ -138,12 +139,6 @@ export function SortableTabContextMenu({ const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) - const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { - if (!isActive) { - onActivate(tab.id) - } - requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) - } const closeShortcut = useOptionalShortcutLabel('tab.close') const renameShortcut = useOptionalShortcutLabel('tab.rename') @@ -158,13 +153,23 @@ export function SortableTabContextMenu({ /> - {canToggleViewMode && onToggleViewMode && ( + + {canToggleViewMode && onToggleViewMode ? ( <> + {isChatView ? ( - + ) : ( - + )} {isChatView ? translate( @@ -176,42 +181,31 @@ export function SortableTabContextMenu({ 'Switch to chat view' )} - - )} - splitActiveTerminalPane('vertical')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', - 'Split terminal right' - )} - {splitRightShortcut} - - splitActiveTerminalPane('horizontal')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', - 'Split terminal down' - )} - {splitDownShortcut} - - + ) : null} - {isPinned ? : } + {isPinned ? ( + + ) : ( + + )} {isPinned ? translate('auto.components.tab.bar.SortableTabContextMenu.417722e9c2', 'Unpin Tab') : translate('auto.components.tab.bar.SortableTabContextMenu.60f958ec75', 'Pin Tab')} !isPinned && onClose(tab.id)} disabled={isPinned}> + {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> + {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} onCloseToRight(tab.id)} disabled={!hasTabsToRight}> + {translate( 'auto.components.tab.bar.SortableTabContextMenu.c1ee099c7e', 'Close Tabs To The Right' @@ -219,6 +213,7 @@ export function SortableTabContextMenu({ + {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx index 819bb6d328f..b1ae2d1cf28 100644 --- a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -5,12 +5,26 @@ import { DropdownMenuSubTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns2 } from 'lucide-react' import type { TabSplitDirection } from '../../store/slices/tabs' import { translate } from '@/i18n/i18n' import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up'] +function paneColumnDirectionIcon(direction: TabSplitDirection): React.JSX.Element { + switch (direction) { + case 'right': + return + case 'left': + return + case 'down': + return + case 'up': + return + } +} + function paneColumnDirectionLabel(direction: TabSplitDirection): string { switch (direction) { case 'right': @@ -26,10 +40,12 @@ function paneColumnDirectionLabel(direction: TabSplitDirection): string { export function TabWorkspaceLayoutMenuSection({ unifiedTabId, - groupId + groupId, + trailingSeparator = false }: { unifiedTabId: string groupId: string + trailingSeparator?: boolean }): React.JSX.Element | null { if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) { return null @@ -37,9 +53,9 @@ export function TabWorkspaceLayoutMenuSection({ return ( <> - - + + {translate( 'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn', 'Move Tab to Split' @@ -53,11 +69,13 @@ export function TabWorkspaceLayoutMenuSection({ moveTabToNewPaneColumn({ unifiedTabId, groupId, direction }) }} > + {paneColumnDirectionIcon(direction)} {paneColumnDirectionLabel(direction)} ))} + {trailingSeparator ? : null} ) } diff --git a/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx new file mode 100644 index 00000000000..7b75e42f4bc --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { PanelBottomClose, PanelRightClose, SquareTerminal } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' + +export function TerminalTabSplitMenuSection({ + unifiedTabId, + groupId, + tabId, + isActive, + onActivate, + splitRightShortcut, + splitDownShortcut, + trailingSeparator = false +}: { + unifiedTabId: string + groupId: string + tabId: string + isActive: boolean + onActivate: (tabId: string) => void + splitRightShortcut: string + splitDownShortcut: string + trailingSeparator?: boolean +}): React.JSX.Element { + const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { + if (!isActive) { + onActivate(tabId) + } + requestActiveTerminalPaneSplit({ tabId, direction }) + } + + return ( + <> + + + + + {translate( + 'auto.components.tab.bar.TerminalTabSplitMenuSection.splitTerminal', + 'Split terminal' + )} + + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} + + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + + + + {trailingSeparator ? : null} + + ) +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 57dbf5946fc..e6f237c9bc3 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -29,6 +29,10 @@ export default function TabGroupPanel({ hasSplitGroups, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge = false, + suppressLeftBorder = false, + suppressRightBorder = false, + suppressBottomBorder = false, reserveClosedExplorerToggleSpace, reserveCollapsedSidebarHeaderSpace, isTabDragActive = false, @@ -40,6 +44,10 @@ export default function TabGroupPanel({ hasSplitGroups: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge?: boolean + suppressLeftBorder?: boolean + suppressRightBorder?: boolean + suppressBottomBorder?: boolean reserveClosedExplorerToggleSpace: boolean reserveCollapsedSidebarHeaderSpace: boolean isTabDragActive?: boolean @@ -205,7 +213,13 @@ export default function TabGroupPanel({ // border-l/border-r in those spots stacks a second 1px line // next to it, reading as a ~2px bar below the drag strip // (where the sibling border continues alone above). - ` ${touchesLeftEdge ? '' : 'border-l'} ${touchesRightEdge ? '' : 'border-r'} border-border border-b ${isFocused ? 'border-b-accent' : 'opacity-95'}` + ` ${ + touchesLeftEdge || suppressLeftBorder ? '' : 'border-l' + } ${touchesRightEdge || suppressRightBorder ? '' : 'border-r'} ${ + touchesBottomEdge || suppressBottomBorder ? '' : 'border-b' + } border-border ${ + isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : '' + } ${isFocused ? '' : 'opacity-95'}` : '' }`} onPointerDown={commands.focusGroup} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index db37a19d241..04cc6536249 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -103,9 +103,9 @@ function ResizeHandle({ return (
) @@ -121,6 +121,10 @@ function SplitNode({ touchesTopEdge, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge, + suppressLeftBorder, + suppressRightBorder, + suppressBottomBorder, isTabDragActive, hoveredTabInsertion }: { @@ -133,6 +137,10 @@ function SplitNode({ touchesTopEdge: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge: boolean + suppressLeftBorder: boolean + suppressRightBorder: boolean + suppressBottomBorder: boolean isTabDragActive: boolean hoveredTabInsertion: HoveredTabInsertion | null }): React.JSX.Element { @@ -152,6 +160,10 @@ function SplitNode({ hasSplitGroups={hasSplitGroups} touchesRightEdge={touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge} reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge} isTabDragActive={isTabDragActive} @@ -181,6 +193,12 @@ function SplitNode({ touchesTopEdge={touchesTopEdge} touchesRightEdge={isHorizontal ? false : touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={isHorizontal ? touchesBottomEdge : false} + suppressLeftBorder={suppressLeftBorder} + // Why: the resize handle paints the inner seam — pane borders here + // stack into a triple-line bar beside the divider. + suppressRightBorder={isHorizontal ? true : suppressRightBorder} + suppressBottomBorder={isHorizontal ? suppressBottomBorder : true} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -201,6 +219,10 @@ function SplitNode({ touchesTopEdge={isHorizontal ? touchesTopEdge : false} touchesRightEdge={touchesRightEdge} touchesLeftEdge={isHorizontal ? false : touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={isHorizontal ? true : suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -275,6 +297,10 @@ export default function TabGroupSplitLayout({ touchesTopEdge={true} touchesRightEdge={true} touchesLeftEdge={true} + touchesBottomEdge={false} + suppressLeftBorder={false} + suppressRightBorder={false} + suppressBottomBorder={false} isTabDragActive={dragSplit.activeDrag !== null} hoveredTabInsertion={dragSplit.hoveredTabInsertion} /> diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7ea7146ec6f..d15a81c4661 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Close tab", "a768f428f1": "Close tab" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cc51dd41b99..84d5a861a54 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Cerrar pestaña", "a768f428f1": "Cerrar pestaña" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a70953da324..20e253a584d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "タブを閉じる", "a768f428f1": "タブを閉じる" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 511f3b1e3c8..cd83a58fa86 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "탭 닫기", "a768f428f1": "탭 닫기" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d46dfc98f40..5be00891377 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "关闭选项卡", "a768f428f1": "关闭选项卡" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, From 599f9f2138d800e961e3efa82eb1944a24b41737 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:33:41 -0700 Subject: [PATCH 029/152] Update WeChat community info in localized READMEs - Remove the WeChat community section from the Portuguese translation. - Add the main and backup WeChat QR codes to the Chinese translation. --- docs/readme/README.pt.md | 4 ---- docs/readme/README.zh-CN.md | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 6c3fc52354c..3ba8d351bf9 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios. -- **WeChat:** Escaneie o QR code para entrar na comunidade. - - QR code do WeChat para a comunidade Orca - - **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues). - **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar. - **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 4c538b08f22..d17ae0a8df8 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,6 +235,11 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 +- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 + + Orca 社区微信群二维码 + Orca 社区备用微信群二维码 + - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 - **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。 From fed545d330ae5d3ef92c688a5c8eb902801e5b94 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:43:53 -0700 Subject: [PATCH 030/152] Stop wiping the shared WebGL glyph atlas on plain window refocus (#7604) Co-authored-by: Orca --- src/main/index.ts | 5 + src/main/system-resume-broadcast.test.ts | 64 +++++++++++++ src/main/system-resume-broadcast.ts | 39 ++++++++ src/preload/api-types.ts | 1 + src/preload/index.ts | 5 + .../terminal-visibility-resume.test.ts | 47 +++++++++- .../terminal-visibility-resume.ts | 19 +++- .../use-terminal-pane-global-effects.test.ts | 94 ++++++++++++++++++- .../use-terminal-window-wake-recovery.ts | 35 ++++++- .../src/lib/pane-manager/pane-manager.ts | 8 +- .../pane-manager/pane-reveal-repaint.test.ts | 30 +++++- .../lib/pane-manager/pane-reveal-repaint.ts | 44 +++++++-- src/renderer/src/web/web-preload-api.ts | 3 + 13 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 src/main/system-resume-broadcast.test.ts create mode 100644 src/main/system-resume-broadcast.ts diff --git a/src/main/index.ts b/src/main/index.ts index 251ea69e67d..e9270e16478 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -151,6 +151,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { getCrashBreadcrumbSnapshot, recordCoalescedCrashBreadcrumb, @@ -212,6 +213,7 @@ let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null +let unsubscribeSystemResumeBroadcast: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null @@ -1653,6 +1655,7 @@ app.whenReady().then(async () => { // Why: browser sessions are used by desktop webviews and runtime profile // commands, so initialize them at app startup instead of a renderer IPC path. initializeBrowserSessionsForApp() + unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: disk-hydrated status rows are UI continuity only. The service starts @@ -2170,6 +2173,8 @@ app.whenReady().then(async () => { app.on('before-quit', () => { isQuitting = true + unsubscribeSystemResumeBroadcast?.() + unsubscribeSystemResumeBroadcast = null unsubscribeAgentAwakeStatusChanges?.() unsubscribeAgentAwakeStatusChanges = null agentAwakeService?.dispose() diff --git a/src/main/system-resume-broadcast.test.ts b/src/main/system-resume-broadcast.test.ts new file mode 100644 index 00000000000..dc67ee27a8a --- /dev/null +++ b/src/main/system-resume-broadcast.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerSystemResumeBroadcast, SYSTEM_RESUMED_CHANNEL } from './system-resume-broadcast' + +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + powerMonitor: { on: vi.fn(), off: vi.fn() } +})) + +type ResumeListener = () => void + +function createResumeSource() { + const state: { listener: ResumeListener | null } = { listener: null } + const source = { + on: vi.fn((_event: 'resume', callback: ResumeListener) => { + state.listener = callback + }), + off: vi.fn((_event: 'resume', _callback: ResumeListener) => { + state.listener = null + }) + } + return { source, fireResume: () => state.listener?.() } +} + +function createWindow(destroyed = false): { + isDestroyed: () => boolean + webContents: { send: ReturnType void>> } +} { + return { + isDestroyed: () => destroyed, + webContents: { send: vi.fn<(channel: string) => void>() } + } +} + +describe('registerSystemResumeBroadcast', () => { + it('broadcasts the resume channel to every live window', () => { + const { source, fireResume } = createResumeSource() + const liveWindow = createWindow() + const destroyedWindow = createWindow(true) + registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [liveWindow, destroyedWindow] + }) + + fireResume() + + expect(liveWindow.webContents.send).toHaveBeenCalledWith(SYSTEM_RESUMED_CHANNEL) + expect(destroyedWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('stops broadcasting after unsubscribe', () => { + const { source, fireResume } = createResumeSource() + const window = createWindow() + const unsubscribe = registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [window] + }) + + unsubscribe() + fireResume() + + expect(source.off).toHaveBeenCalledTimes(1) + expect(window.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/system-resume-broadcast.ts b/src/main/system-resume-broadcast.ts new file mode 100644 index 00000000000..990b8d2a66c --- /dev/null +++ b/src/main/system-resume-broadcast.ts @@ -0,0 +1,39 @@ +import { BrowserWindow, powerMonitor } from 'electron' + +export const SYSTEM_RESUMED_CHANNEL = 'system:resumed' + +type ResumeEventSource = { + on(event: 'resume', listener: () => void): unknown + off(event: 'resume', listener: () => void): unknown +} + +type ResumeBroadcastWindow = { + isDestroyed(): boolean + webContents: { send(channel: string): void } +} + +type SystemResumeBroadcastOptions = { + resumeSource?: ResumeEventSource + getWindows?: () => ResumeBroadcastWindow[] +} + +// Why: renderers cannot observe OS sleep/wake directly, and Linux has no +// window-occlusion tracking so visibilitychange never fires around suspend. +// Wake-sensitive renderer recovery needs this explicit resume signal. +export function registerSystemResumeBroadcast( + options: SystemResumeBroadcastOptions = {} +): () => void { + const resumeSource = options.resumeSource ?? powerMonitor + const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows()) + const onResume = (): void => { + for (const window of getWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(SYSTEM_RESUMED_CHANNEL) + } + } + } + resumeSource.on('resume', onResume) + return () => { + resumeSource.off('resume', onResume) + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a0e6c9bea81..184e0aea648 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2679,6 +2679,7 @@ export type PreloadApi = { ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise saveClipboardImageAsTempFile: (args?: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 727bee949bd..f0ef9cf7e34 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3454,6 +3454,11 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index e8b59a3a436..b42304cb851 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -31,13 +31,15 @@ type FakeManager = { getPanes: ReturnType resumeRendering: ReturnType scheduleRevealRepaint: ReturnType + scheduleRevealPresent: ReturnType } function createManager(order: string[] = []): FakeManager { return { getPanes: vi.fn(() => []), resumeRendering: vi.fn(() => order.push('resume-rendering')), - scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), + scheduleRevealPresent: vi.fn(() => order.push('reveal-present')) } } @@ -79,13 +81,52 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(order).toEqual(['resume-rendering', 'reveal-repaint']) }) - it('schedules the repaint on window-wake recovery', () => { + it('schedules the atlas-clearing repaint on genuine wake recovery', () => { const manager = createManager() recoverVisibleTerminalWindowWake({ manager: manager as never as PaneManager, - isActive: false + isActive: false, + clearGlyphAtlases: true }) expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealPresent).not.toHaveBeenCalled() + }) + + it('clears shared glyph atlases only on genuine wake recovery', async () => { + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false, + clearGlyphAtlases: true + }) + + expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + }) + + it('keeps the warm glyph atlas on plain-refocus recovery', async () => { + // Deliberate reversal of the #6354 focus-clear: wiping the shared atlas on + // every refocus forces a mass re-rasterization that can hit xterm's atlas + // page-merge race (#4480) and garble streaming panes. Focus recovery must + // resume rendering and present WITHOUT the atlas-clearing reveal repaint — + // scheduleRevealRepaint clears each pane's (shared) atlas, so the refocus + // path must route to the atlas-preserving present instead. + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false, + clearGlyphAtlases: false + }) + + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 0c821e5d030..d1a0cc9e20f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -40,6 +40,7 @@ type HideTerminalVisibilityResult = { type RecoverVisibleTerminalWindowWakeArgs = { manager: PaneManager isActive: boolean + clearGlyphAtlases: boolean } export function resumeTerminalVisibility({ @@ -123,7 +124,8 @@ export function hideTerminalVisibility({ export function recoverVisibleTerminalWindowWake({ manager, - isActive + isActive, + clearGlyphAtlases }: RecoverVisibleTerminalWindowWakeArgs): void { // Why: macOS screensaver/display wake can leave xterm visible but with a // stale renderer/input surface; Orca's own hidden-state resume never runs. @@ -138,8 +140,19 @@ export function recoverVisibleTerminalWindowWake({ fitPanes(manager) } enforceTerminalViewportIntents(manager) - resetAndRefreshAllTerminalWebglAtlases() - manager.scheduleRevealRepaint() + if (clearGlyphAtlases) { + // Why: only a genuine wake may wipe the shared glyph atlas. The wipe makes + // every same-config pane re-rasterize at once, and xterm's atlas page-merge + // clear-model flag is consumed by one renderer (xterm.js #4480), so panes + // that lose that race paint garbled glyphs mid-stream. + resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() + } else { + // Why: the reveal repaint clears each pane's texture atlas (a shared, + // same-config wipe), so a plain refocus must use the atlas-preserving + // present instead — otherwise it re-arms the same mid-stream garble race. + manager.scheduleRevealPresent() + } } function requestLightTabBacklogRecovery(manager: PaneManager): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 0ad8f80d141..f8355d8b31b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -131,6 +131,7 @@ function useMountForFileDrop( resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType scheduleRevealRepaint: ReturnType + scheduleRevealPresent: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -148,6 +149,7 @@ function useMountForFileDrop( resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -225,6 +227,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), + scheduleRevealPresent: vi.fn(() => order.push('reveal-present')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -313,6 +316,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -393,6 +397,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -455,6 +460,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -509,6 +515,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -587,6 +594,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -619,6 +627,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -672,18 +681,22 @@ describe('useTerminalPaneGlobalEffects', () => { expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA) }) - it('clears WebGL texture atlases when the active visible terminal regains focus', () => { + it('keeps the shared glyph atlas warm on plain window refocus', () => { const manager = { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } - // Why: focus recovery resets every registered manager (shared glyph - // atlas), so the fake manager observes the reset through the registry. + // Why: deliberate reversal of the #6354 focus-clear. A refocus atlas wipe + // forces every pane to re-rasterize at once, and xterm's page-merge + // clear-model flag is consumed by a single renderer (#4480), so panes that + // lose the race paint garbled glyphs while an agent streams. Focus must + // stay a WebGL-retry + pane-scoped repaint boundary only. registerManagerForReset(manager) beginHookRender() useTerminalPaneGlobalEffects({ @@ -711,9 +724,18 @@ describe('useTerminalPaneGlobalEffects', () => { throw new Error('expected focus listener') } manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() + listener(new Event('focus')) + listener(new Event('focus')) listener(new Event('focus')) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + // Count proof: repeated refocus performs zero shared-atlas wipes. It routes + // to the atlas-preserving present (scheduleRevealPresent), never the + // atlas-clearing reveal repaint, which would clear each pane's shared atlas. + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(3) }) it('recovers visible terminal rendering and input when the window regains focus', () => { @@ -723,6 +745,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -757,6 +780,10 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() manager.refreshAllPanes.mockClear() + // Clear the mount-time reveal spies so the assertions measure only the + // focus event, not the initial visibility resume. + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() mocks.fitAndFocusPanes.mockClear() mocks.flushTerminalOutput.mockClear() mocks.requestTerminalBacklogRecovery.mockClear() @@ -767,6 +794,59 @@ describe('useTerminalPaneGlobalEffects', () => { expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 64 * 1024 }) expect(manager.resumeRendering).toHaveBeenCalledTimes(1) expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + // Why: refocus recovery is atlas-preserving — no shared-atlas reset, no + // registry-wide repaint, and no atlas-clearing reveal repaint; the + // atlas-preserving present covers stale pixels. + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) + }) + + it('clears WebGL texture atlases when the OS resumes', () => { + const manager = { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), + refreshAllPanes: vi.fn(), + suspendRendering: vi.fn(), + getActivePane: vi.fn(() => null) + } + const captured: { onSystemResumed: (() => void) | null } = { onSystemResumed: null } + const unsubscribeSystemResumed = vi.fn() + ;( + window.api.ui as unknown as { onSystemResumed: (callback: () => void) => () => void } + ).onSystemResumed = vi.fn((callback: () => void) => { + captured.onSystemResumed = callback + return unsubscribeSystemResumed + }) + + registerManagerForReset(manager) + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + isSyncFitEnabled: true, + paneCount: 0, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + + expect(captured.onSystemResumed).toBeTypeOf('function') + manager.resetWebglTextureAtlases.mockClear() + manager.refreshAllPanes.mockClear() + captured.onSystemResumed?.() + + // Why: OS resume is a genuine wake — GPU state may be stale without a + // context-loss event, so the shared-atlas clear and full repaint still run. expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) }) @@ -788,6 +868,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -843,6 +924,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -889,6 +971,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -945,6 +1028,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1061,6 +1145,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1095,6 +1180,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 8d07259f7ae..8c5da7ed97b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -20,6 +20,7 @@ export function useTerminalWindowWakeRecovery({ return } let wakeRecoveryFrameId: number | null = null + let settledClearGlyphAtlases = false const cancelScheduledWakeRecovery = (): void => { if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { wakeRecoveryFrameId = null @@ -28,9 +29,12 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } - const recoverVisibleWake = (): void => { + const recoverVisibleWake = (clearGlyphAtlases: boolean): void => { // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. if (wakeRecoveryFrameId !== null) { + // Why: a pending settled pass may only upgrade in strength — a plain + // focus that lands after a genuine wake must not skip its atlas clear. + settledClearGlyphAtlases ||= clearGlyphAtlases return } const manager = managerRef.current @@ -39,39 +43,60 @@ export function useTerminalWindowWakeRecovery({ } recoverVisibleTerminalWindowWake({ manager, - isActive: isActiveRef.current + isActive: isActiveRef.current, + clearGlyphAtlases }) if (typeof requestAnimationFrame !== 'function') { return } + settledClearGlyphAtlases = clearGlyphAtlases wakeRecoveryFrameId = requestAnimationFrame(() => { wakeRecoveryFrameId = null + const clearGlyphAtlasesOnSettle = settledClearGlyphAtlases + settledClearGlyphAtlases = false const settledManager = managerRef.current if (!settledManager || !isVisibleRef.current) { return } recoverVisibleTerminalWindowWake({ manager: settledManager, - isActive: isActiveRef.current + isActive: isActiveRef.current, + clearGlyphAtlases: clearGlyphAtlasesOnSettle }) }) } - const onFocus = (): void => recoverVisibleWake() + // Why: plain refocus (alt-tab, devtools) is frequent and often lands while + // an agent streams; wiping the shared glyph atlas then provokes xterm's + // page-merge race and paints garbled glyphs. Focus recovery keeps the warm + // atlas: it only retries WebGL attach, refits, and repaints pane-scoped. + const onFocus = (): void => recoverVisibleWake(false) const onVisibilityChange = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverVisibleWake() + recoverVisibleWake(true) + } + } + // Why: Linux has no window-occlusion tracking, so visibilitychange never + // fires around system suspend; the main process broadcasts OS resume. + const onSystemResumed = (): void => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + recoverVisibleWake(true) } } window.addEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + const unsubscribeSystemResumed = + typeof window.api?.ui?.onSystemResumed === 'function' + ? window.api.ui.onSystemResumed(onSystemResumed) + : null return () => { cancelScheduledWakeRecovery() window.removeEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', onVisibilityChange) } + unsubscribeSystemResumed?.() } }, [isActiveRef, isVisible, isVisibleRef, managerRef]) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 77ad5398890..f04d5d9c3e2 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -41,7 +41,7 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' -import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, @@ -329,6 +329,12 @@ export class PaneManager { schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) } + scheduleRevealPresent(): void { + // Why: same destroy guard as scheduleRevealRepaint, but presents without + // clearing the shared glyph atlas — used by the plain-refocus recovery. + schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 564a57e9875..4985ffe1355 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' -import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint' import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' type FakeWebglAddon = { clearTextureAtlas: ReturnType } @@ -144,4 +144,32 @@ describe('schedulePaneRevealRepaint', () => { expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) vi.useRealTimers() }) + + describe('schedulePaneRevealPresent', () => { + it('presents the settled buffer without wiping the shared glyph atlas', () => { + // The plain-refocus path must NOT clear the atlas — the clear is a + // same-config shared wipe that re-arms the mid-stream page-merge race. + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealPresent(() => [pane]) + + flushFrame() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('still retries a missing WebGL attach on the settled frame', () => { + const pane = createPane() + schedulePaneRevealPresent(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index 48df07b9b0c..9fb283782d2 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -15,6 +15,21 @@ function scheduleSettledFrame(callback: () => void): void { }) } +function forEachPaneOnSettledFrame( + getPanes: () => Iterable, + visit: (pane: ManagedPaneInternal) => void +): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + visit(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} + /** * Repaints a revealed tab's panes from their xterm buffers. * @@ -26,14 +41,27 @@ function scheduleSettledFrame(callback: () => void): void { * settled — forces a full rebuild from the buffer without any PTY resize. */ export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { - scheduleSettledFrame(() => { - for (const pane of getPanes()) { - try { - reattachWebglIfNeeded(pane) - resetWebglTextureAtlas(pane) - } catch { - /* ignore — one pane's failure must not block repaint of the rest */ - } + forEachPaneOnSettledFrame(getPanes, (pane) => { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + }) +} + +/** + * Presents already-visible panes without clearing the shared glyph atlas. + * + * Why: a plain window refocus never hid its panes, so their WebGL model is + * already current — a `refresh` re-presents the live buffer (covering a + * compositor that dropped frames while occluded). Using the atlas-clearing + * reveal repaint here would wipe the atlas shared by every same-config pane and + * re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path + * must stay texture-atlas-preserving. + */ +export function schedulePaneRevealPresent(getPanes: () => Iterable): void { + forEachPaneOnSettledFrame(getPanes, (pane) => { + reattachWebglIfNeeded(pane) + if (pane.terminal.rows > 0) { + pane.terminal.refresh(0, pane.terminal.rows - 1) } }) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5cf3a3c8ea8..eb46da67724 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2223,6 +2223,9 @@ function createWebUiApi(): NonNullable['ui']> { onCloseTerminal: () => noopUnsubscribe, onSleepWorktree: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, + // Why: a paired web client has no OS sleep signal; occlusion-driven + // visibilitychange already covers its wake recovery. + onSystemResumed: () => noopUnsubscribe, onFileDrop: () => noopUnsubscribe, syncTrafficLights: () => {}, setMarkdownEditorFocused: () => {}, From 0fe20ddff9b69126d31dfd2002d7d4e55b5e6032 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:55:41 -0700 Subject: [PATCH 031/152] Fix Monaco Peek References preview collapse (#7662) Co-authored-by: Orca --- src/renderer/src/assets/main.css | 10 ++ .../lib/monaco-peek-preview-options.test.ts | 98 +++++++++++++++++++ .../src/lib/monaco-peek-preview-options.ts | 67 +++++++++++++ src/renderer/src/lib/monaco-setup.ts | 2 + 4 files changed, 177 insertions(+) create mode 100644 src/renderer/src/lib/monaco-peek-preview-options.test.ts create mode 100644 src/renderer/src/lib/monaco-peek-preview-options.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b77b58a5c1a..c0abe44dea5 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1971,6 +1971,16 @@ pointer-events: auto; } +/* Why: Monaco leaves the peek preview container `inline-block` and only sizes the + editor child, so the container shrink-wraps the child in BOTH axes while the + inherited automaticLayout ResizeObserver measures the container — a feedback + loop that ratchets the preview to blank at fractional zoom/DPI (issue #7405). + Pinning both dimensions to the splitview slot severs the loop. */ +.monaco-editor .reference-zone-widget .preview.inline { + height: 100%; + width: 100%; +} + /* ── Git conflict decorations ───────────────────────────────────── */ .monaco-editor .orca-conflict-marker-line { diff --git a/src/renderer/src/lib/monaco-peek-preview-options.test.ts b/src/renderer/src/lib/monaco-peek-preview-options.test.ts new file mode 100644 index 00000000000..62e3952aa51 --- /dev/null +++ b/src/renderer/src/lib/monaco-peek-preview-options.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import type * as Monaco from 'monaco-editor' +import { installMonacoPeekReferencesPreviewOptions } from './monaco-peek-preview-options' + +type FakePreviewEditor = Pick + +type FakeReferenceWidgetInstance = { + _preview?: FakePreviewEditor + _fillBody?: (containerElement: HTMLElement) => void + _revealReference?: (...args: unknown[]) => Promise +} + +function createReferenceWidgetConstructor(hooks: { + fillBody?: (instance: FakeReferenceWidgetInstance) => void + revealReference?: (instance: FakeReferenceWidgetInstance) => Promise +}): { + prototype: FakeReferenceWidgetInstance & + Required> +} { + return { + prototype: { + _fillBody(this: FakeReferenceWidgetInstance): void { + hooks.fillBody?.(this) + }, + async _revealReference(this: FakeReferenceWidgetInstance): Promise { + return hooks.revealReference?.(this) + } + } + } +} + +function createPreviewEditor(): FakePreviewEditor { + return { updateOptions: vi.fn() } +} + +const peekPreviewOptions = { + smoothScrolling: false, + stickyScroll: { enabled: false }, + wordWrap: 'off' +} + +describe('installMonacoPeekReferencesPreviewOptions', () => { + it('updates the embedded preview after ReferenceWidget creates it', () => { + const preview = createPreviewEditor() + const referenceWidget = createReferenceWidgetConstructor({ + fillBody(instance) { + instance._preview = preview + } + }) + + installMonacoPeekReferencesPreviewOptions(referenceWidget) + referenceWidget.prototype._fillBody(document.createElement('div')) + + expect(preview.updateOptions).toHaveBeenCalledWith(peekPreviewOptions) + }) + + it('updates the embedded preview before revealing a reference', async () => { + const calls: string[] = [] + const preview: FakePreviewEditor = { + updateOptions: vi.fn(() => calls.push('updateOptions')) + } + const referenceWidget = createReferenceWidgetConstructor({ + revealReference: async () => { + calls.push('revealReference') + } + }) + referenceWidget.prototype._preview = preview + + installMonacoPeekReferencesPreviewOptions(referenceWidget) + await referenceWidget.prototype._revealReference('reference') + + expect(calls).toEqual(['updateOptions', 'revealReference', 'updateOptions']) + expect(preview.updateOptions).toHaveBeenCalledWith(peekPreviewOptions) + }) + + it('skips patching when the private Monaco hooks are missing', () => { + const referenceWidget: { prototype: FakeReferenceWidgetInstance } = { prototype: {} } + + installMonacoPeekReferencesPreviewOptions(referenceWidget) + + expect(referenceWidget.prototype._fillBody).toBeUndefined() + expect(referenceWidget.prototype._revealReference).toBeUndefined() + }) + + it('does not wrap ReferenceWidget more than once', async () => { + const preview = createPreviewEditor() + const referenceWidget = createReferenceWidgetConstructor({}) + referenceWidget.prototype._preview = preview + + installMonacoPeekReferencesPreviewOptions(referenceWidget) + installMonacoPeekReferencesPreviewOptions(referenceWidget) + await referenceWidget.prototype._revealReference('reference') + + expect(preview.updateOptions).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/monaco-peek-preview-options.ts b/src/renderer/src/lib/monaco-peek-preview-options.ts new file mode 100644 index 00000000000..8927ed7721f --- /dev/null +++ b/src/renderer/src/lib/monaco-peek-preview-options.ts @@ -0,0 +1,67 @@ +import type * as Monaco from 'monaco-editor' +import { ReferenceWidget as MonacoReferenceWidget } from 'monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.js' + +type PeekPreviewEditor = Pick + +const PEEK_REFERENCES_PREVIEW_OPTIONS: Monaco.editor.IEditorOptions = { + smoothScrolling: false, + stickyScroll: { enabled: false }, + wordWrap: 'off' +} + +type ReferenceWidgetInstance = { + _preview?: PeekPreviewEditor + _fillBody?: (containerElement: HTMLElement) => void + _revealReference?: (...args: unknown[]) => Promise +} + +type ReferenceWidgetConstructor = { + prototype: ReferenceWidgetInstance & { + __orcaPeekPreviewOptionsInstalled?: true + } +} + +function applyPeekReferencesPreviewOptions(editor: PeekPreviewEditor | undefined): void { + // Why: Monaco embedded editors inherit Orca's full-editor options first. + // Peek previews are transient readers, so keep scroll/wrap widgets out of them. + editor?.updateOptions(PEEK_REFERENCES_PREVIEW_OPTIONS) +} + +export function installMonacoPeekReferencesPreviewOptions( + referenceWidget: ReferenceWidgetConstructor = MonacoReferenceWidget as unknown as ReferenceWidgetConstructor +): void { + const prototype = referenceWidget.prototype + if (prototype.__orcaPeekPreviewOptionsInstalled) { + return + } + + const originalFillBody = prototype._fillBody + const originalRevealReference = prototype._revealReference + // Why: these are private Monaco members with no stability guarantee; if an + // upgrade removes either, skip patching so Peek keeps Monaco's defaults. + if (typeof originalFillBody !== 'function' || typeof originalRevealReference !== 'function') { + return + } + + prototype._fillBody = function fillBodyWithPeekPreviewOptions( + this: ReferenceWidgetInstance, + containerElement: HTMLElement + ): void { + originalFillBody.call(this, containerElement) + applyPeekReferencesPreviewOptions(this._preview) + } + + prototype._revealReference = async function revealReferenceWithPeekPreviewOptions( + this: ReferenceWidgetInstance, + ...args: unknown[] + ): Promise { + applyPeekReferencesPreviewOptions(this._preview) + try { + return await originalRevealReference.apply(this, args) + } finally { + applyPeekReferencesPreviewOptions(this._preview) + } + } + + prototype.__orcaPeekPreviewOptionsInstalled = true +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 67d05eaefc3..8a955e99d1c 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -13,6 +13,7 @@ import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard' import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal' +import { installMonacoPeekReferencesPreviewOptions } from './monaco-peek-preview-options' import { installMonacoContextMenuPaste } from '@/components/editor/install-monaco-context-menu-paste' globalThis.MonacoEnvironment = { @@ -78,6 +79,7 @@ registerAstroLanguage(monaco) registerNimLanguage(monaco) installMonacoDelayerCancellationGuard() installMonacoDiffEditorDisposalGuard(monaco) +installMonacoPeekReferencesPreviewOptions() // Why: Monaco's built-in context-menu Paste reads navigator.clipboard, which is // blocked in Orca's sandboxed renderer. Route it through the trusted IPC bridge // so right-click Paste works like Cmd+V (which already works via native events). From ee6405052a3ff06f1424431b711333800804d7c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:17:13 -0700 Subject: [PATCH 032/152] Keep terminals created from Orca Mobile alive when the desktop renderer is throttled or stalled (#7664) Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 102 +++++++ src/main/ipc/pty.ts | 31 +- src/main/runtime/orca-runtime.test.ts | 407 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 125 +++++++- 4 files changed, 656 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 60a8bb5da3a..172114ce6aa 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1609,6 +1609,47 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + tabId?: string + leafId?: string + env?: Record + }): Promise<{ id: string }> + } + const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + tabId: 'tab-1', + leafId + }) + + // Why: runtime-created spawns (e.g. the mobile-create materialize path) + // must thread the same {tabId, leafId} so the catch-path rescue can find + // and keep their live PTY (#7587). + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-runtime', null, { + tabId: 'tab-1', + leafId + }) + }) + it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { await withWin32Platform(async () => { _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) @@ -4246,6 +4287,67 @@ describe('registerPtyHandlers', () => { ) }) + it('threads the validated pane identity into registerPty for a renderer PTY spawn (#7587)', async () => { + const leafId = '88888888-8888-4888-8888-888888888888' + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_seam'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1' + }) + + // Why: this is the load-bearing wiring for #7587 — the runtime can only back a + // stalled mobile create from a live spawn if the spawn threads {tabId, leafId}. + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, { + tabId: 'tab-1', + leafId + }) + }) + + it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => { + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_seam'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + tabId: 'tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + }) + + // Why: legacy numeric pane ids (`pane:N`) are not terminal leaf ids, so the + // spawn seam must not fabricate a binding for them (registerPty would ignore + // it anyway); this pins that the seam passes a clean `undefined`. + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, undefined) + }) + it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => { const leafId = '11111111-1111-4111-8111-111111111111' const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e12c2b7f262..d7a5b1986e9 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2354,7 +2354,20 @@ export function registerPtyHandlers( runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) } if (args.worktreeId) { - runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) + runtime?.registerPty( + result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: thread the validated pane identity so main can back a pending + // mobile create from this live spawn even if graph-sync stalls (#7587). + // Bound tabId like the sibling metadataPaneKey/spawnOptions.tabId here. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId !== null + ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined + ) } if (isClaudeLaunch) { markClaudePtySpawned(result.id) @@ -3233,7 +3246,21 @@ export function registerPtyHandlers( args.worktreeId.length > 0 && args.worktreeId.length <= 512 ) { - runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) + runtime?.registerPty( + result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass the validated pane identity so a mobile create waiting on + // this renderer tab can publish its surface main-side when graph-sync + // is throttled, instead of destroying the live PTY (#7587). Bound the + // untrusted tabId like the sibling metadataPaneKey/spawnOptions.tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId !== null + ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined + ) } if (isClaudeLaunch) { markClaudePtySpawned(result.id) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d2655b3d372..4acf193e0d3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -17957,6 +17957,413 @@ describe('OrcaRuntimeService', () => { } }) + // Why: the five #7587 mobile-create tests need an identical full notifier where + // only closeTerminal is observed; one factory keeps notifier-interface changes + // in a single place. + function createMobileCreateTestNotifier( + closeTerminal: (tabId: string, paneRuntimeId?: number) => void + ) { + return { + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal, + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } + } + + it('rolls back a mobile create when the materialize spawn fails and no live PTY backs the tab', async () => { + vi.useFakeTimers() + try { + const pendingLeafId = '99999999-9999-4999-8999-999999999999' + const closeTerminal = vi.fn() + // Why: the renderer publishes a handle-less shell surface (no ptyId) and the + // runtime materialize spawn then fails, so NO live PTY ever backs the tab. + // The catch must roll back — the #7587 rescue is gated on a live PTY, not on + // the mere presence of a surface, or a dead shell would resolve as success. + const spawn = vi.fn().mockRejectedValue(new Error('spawn failed')) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-pending', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Only the tab shell publishes (no ptyId → never ready); no PTY ever binds. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-pending', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-pending::${pendingLeafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-pending::${pendingLeafId}`, + parentTabId: 'tab-pending', + leafId: pendingLeafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + // Ready-fallback (1s) expires → materialize runs → spawn rejects → catch. + await vi.advanceTimersByTimeAsync(2_000) + const outcome = await settled + + expect(outcome.ok).toBe(false) + expect(closeTerminal).toHaveBeenCalledWith('tab-pending') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer never publishes the surface', async () => { + vi.useFakeTimers() + try { + const leafId = '44444444-4444-4444-8444-444444444444' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: reply with a tabId but NEVER sync a matching mobileSessionTabs graph, + // reproducing a throttled/hidden renderer that spawns the PTY yet stalls its + // graph-sync publication past the surface timeout (#7587). + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-alive', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The renderer's own PTY spawn registers with the tab binding — the same + // call the pty IPC layer now makes — without any mobileSessionTabs sync. + runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { + tabId: 'tab-alive', + leafId + }) + + // Resolves promptly, well under MOBILE_TERMINAL_SURFACE_TIMEOUT_MS (10s). + await vi.advanceTimersByTimeAsync(50) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-alive', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer PTY spawn races ahead of the reply', async () => { + vi.useFakeTimers() + try { + const leafId = '55555555-5555-4555-8555-555555555555' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: the spawn and the tabCreate reply travel independent IPC channels; + // here the renderer registers the live PTY before it replies, so only the + // create's immediate pre-wait check can resolve it — asserting it settles + // well under the surface timeout pins that site, not the catch backstop. + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.registerPty('pty-early', TEST_WORKTREE_ID, null, { + tabId: 'tab-early', + leafId + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-early', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Resolves via the immediate pre-wait rescue, well under the 10s timeout; + // reverting that site would only resolve later through the catch path. + await vi.advanceTimersByTimeAsync(50) + expect(settled).toBe(true) + const result = await settledCreate + + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-early', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer snapshot is rejected by the version guard', async () => { + vi.useFakeTimers() + try { + const leafId = '66666666-6666-4666-8666-666666666666' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-guard', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + // Why: seed an inflated stored version under a stable epoch (no headless / + // serve-owned tabs, so the preserve-merge can't lift the version via + // Math.max) — this is the state prior renderer publications leave behind. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-guard', + snapshotVersion: 50, + activeGroupId: 'group-guard', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The renderer publishes the new tab, but with a stale (lower) version + // under the same epoch, so syncMobileSessionTabs rejects it and the surface + // never lands via graph-sync — the reporter's exact stall variant (#7587). + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-guard', + snapshotVersion: 1, + activeGroupId: 'group-guard', + activeTabId: `tab-guard::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-guard::${leafId}`, + parentTabId: 'tab-guard', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + await vi.advanceTimersByTimeAsync(50) + // The rejected renderer sync must not have resolved the create. + expect(settled).toBe(false) + // Prove the rejection: the stored snapshot still lacks the tab. + const beforeRescue = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + beforeRescue.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'tab-guard') + ).toBe(false) + + // The renderer's own PTY spawn registers with the binding and rescues it. + runtime.registerPty('pty-guard', TEST_WORKTREE_ID, null, { + tabId: 'tab-guard', + leafId + }) + await vi.advanceTimersByTimeAsync(50) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-guard', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive at the surface timeout when only a leaf-synced PTY backs the tab', async () => { + vi.useFakeTimers() + try { + const leafId = '77777777-7777-4777-8777-777777777777' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: the renderer pane identity can reach the runtime via leaf graph-sync + // (recordPtyWorktree) without registerPty, and leaf-sync never calls the + // pty-backed rescue; so when the mobileSessionTabs publication stalls past + // the surface timeout, only the catch-path rescue saves the live session — + // this pins the third #7587 rescue site, which the other tests never reach. + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-catch', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Let the create clear its immediate pre-wait check (no live PTY yet) and + // park in waitForMobileTerminalSurface before any identity arrives. + await vi.advanceTimersByTimeAsync(50) + + // Renderer pane identity arrives via leaf graph-sync — NOT registerPty — so + // no rescue fires here, and the mobileSessionTabs surface is never published. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-catch', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-catch' + } + ] + }) + + // Surface still unpublished, so the create is still pending. + expect(settled).toBe(false) + + // Cross MOBILE_TERMINAL_SURFACE_TIMEOUT_MS (10s): the catch path must rescue + // from the live PTY instead of rolling the session back destructively. + await vi.advanceTimersByTimeAsync(11_000) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-catch', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('reports browser tab creation as unsupported for a windowless host with no offscreen backend', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e6a5bcbfa95..6463423241e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -175,7 +175,7 @@ import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' import { @@ -2011,6 +2011,15 @@ export class OrcaRuntimeService { string, Promise >() + // Why: a mobile create waits for the renderer to publish the new tab's surface + // via graph-sync, but a throttled/hidden renderer can park that past the surface + // timeout and the create would then destroy the live PTY (#7587). This lets the + // renderer's own PTY spawn publish the surface main-side, scoped to in-flight + // creates so ordinary renderer spawns never publish here. + private pendingMobileTerminalCreatesByKey = new Map< + string, + { activate: boolean; selectIfNoActiveTab: boolean } + >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by @@ -5213,8 +5222,28 @@ export class OrcaRuntimeService { } } - registerPty(ptyId: string, worktreeId: string, connectionId: string | null = null): void { - this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId }) + registerPty( + ptyId: string, + worktreeId: string, + connectionId: string | null = null, + binding?: { tabId: string; leafId: string } + ): void { + // Why: record the renderer pane identity at spawn time so a stalled graph + // sync can't hide that a live PTY already backs a pending mobile create. + const paneKey = + binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId) + ? makePaneKey(binding.tabId, binding.leafId) + : null + this.recordPtyWorktree(ptyId, worktreeId, { + connected: true, + connectionId, + ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}) + }) + // Why: the renderer's own PTY spawn is the reliable signal that the pending + // mobile create's tab is live; publish its surface main-side (#7587). + if (binding && paneKey) { + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) + } } /** @@ -16130,7 +16159,22 @@ export class OrcaRuntimeService { if (opts.activate !== false) { this.notifier?.focusTerminal(reply.tabId, worktreeId, null) } + // Why: register the wait before the renderer's PTY spawn arrives so that + // spawn (registerPty) can publish the pty-backed surface main-side even if + // graph-sync is stalled (#7587). Removed in the finally below. + const pendingCreateKey = `${worktreeId}::${reply.tabId}` + // Why: a rescue publishes into the active group (opts.targetGroupId is not + // threaded); the renderer's reconciling publication then moves the tab to the + // requested group, so any wrong-group placement is cosmetic and stall-window-only. + this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { + activate: opts.activate !== false, + selectIfNoActiveTab: true + }) try { + // Why: the PTY spawn and the tabCreate reply race on independent IPC + // channels; if the spawn already registered, publish immediately so the + // wait resolves without depending on a graph sync. + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS }) @@ -16167,12 +16211,24 @@ export class OrcaRuntimeService { } ) } catch (error) { - // Why: the renderer created the tab but its terminal surface never - // published (PTY spawn/handle failure). Roll the half-created tab back via - // the renderer close path so it can't linger as a ghost in mobile - // snapshots, then surface the failure to the caller. + // Why: publication latency (throttled/hidden renderer), not spawn failure, + // can trip the surface timeout. Rescue only when a live PTY actually backs + // the tab — gating on a surface would let a handle-less shell (or a failed + // materialize) resolve as success and skip the ghost-tab rollback (#7587). + if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { + const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) + if (rescued) { + return rescued + } + } + // Why: the renderer created the tab but no live PTY backs it (true PTY + // spawn/handle failure). Roll the half-created tab back via the renderer + // close path so it can't linger as a ghost in mobile snapshots, then + // surface the failure to the caller. this.notifier?.closeTerminal(reply.tabId) throw error + } finally { + this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) } } @@ -16445,6 +16501,61 @@ export class OrcaRuntimeService { return surface } + // Why: for an in-flight mobile create whose surface hasn't published yet, + // publish it main-side from the live renderer PTY so the create doesn't wait + // on a stalled graph sync and destroy the session (#7587). No-op unless a + // matching create is pending and a live bound PTY exists; never double-inserts. + private ensurePtyBackedMobileSurfaceForRendererTab( + worktreeId: string, + tabId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) + if (!pending) { + return null + } + const existing = this.findMobileTerminalSurface(worktreeId, tabId) + if (existing) { + // Why: the renderer's own publication already landed; stay idempotent. + return existing + } + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined + if (!pty || !leafId) { + return null + } + this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { + tabId, + leafId, + title: null, + activate: pending.activate, + selectIfNoActiveTab: pending.selectIfNoActiveTab + }) + // Why: waitForMobileTerminalSurface's check closures are drained only inside + // syncWindowGraph; a main-side publish must drain them too or the pending + // wait won't observe the insertion (mirrors syncWindowGraph's drain). + for (const cb of [...this.graphSyncCallbacks]) { + cb() + } + return this.findMobileTerminalSurface(worktreeId, tabId) + } + + private findLiveRegisteredPtyForRendererTab( + worktreeId: string, + tabId: string + ): RuntimePtyWorktreeRecord | null { + for (const pty of this.ptysById.values()) { + if ( + pty.worktreeId === worktreeId && + pty.tabId === tabId && + pty.connected && + parsePaneKey(pty.paneKey ?? '')?.leafId + ) { + return pty + } + } + return null + } + private isReadyMobileTerminalSurface( surface: RuntimeMobileSessionCreateTerminalResult | null ): boolean { From bea9b385449679600abfeb2792d697f96a27fe3d Mon Sep 17 00:00:00 2001 From: Dvitash Date: Tue, 7 Jul 2026 16:51:25 -0400 Subject: [PATCH 033/152] Fix OMP worktree title identity flicker * Fix OMP worktree title identity flicker * Add headless fresh pairing server script * Harden fresh pairing server script startup * Default fresh pairing server to reachable address * review: harden OMP tab identity validation Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../serve-headless-fresh-profile-pairing.mjs | 326 ++++++++++++++++++ src/renderer/src/lib/use-tab-agent.test.ts | 76 ++++ src/renderer/src/lib/use-tab-agent.ts | 38 +- 3 files changed, 431 insertions(+), 9 deletions(-) create mode 100755 config/scripts/serve-headless-fresh-profile-pairing.mjs diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs new file mode 100755 index 00000000000..6d4f7ac8656 --- /dev/null +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import path from 'node:path' +import { createInterface } from 'node:readline' + +const scriptDir = import.meta.dirname +const repoRoot = path.resolve(scriptDir, '..', '..') +const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs') +const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs') +const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR +const parsed = parseArgs(process.argv.slice(2)) + +if (parsed.help) { + printHelp() + process.exit(0) +} + +if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) { + console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.') + process.exit(2) +} +if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) { + console.error( + 'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.' + ) + process.exit(2) +} + +const serveArgs = withDefaultPairingAddress(parsed.serveArgs) + +ensureElectronRuntime() + +const profileDir = + fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-')) +const ownsProfileDir = !fixedProfileDir +mkdirSync(profileDir, { recursive: true }) + +let cleanedUp = false +let child = null +let sawPairingUrl = false +let stopAttempts = 0 +// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script +// is only for local headless testing, not packaged production. +const childEnv = { + ...process.env, + ORCA_DEV_USER_DATA_PATH: profileDir, + ...(process.platform === 'linux' + ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } + : {}) +} + +console.error(`[headless-pairing] userData=${profileDir}`) +console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`) + +child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: childEnv, + stdio: ['inherit', 'pipe', 'inherit'] +}) + +const stdoutLines = createInterface({ input: child.stdout }) +stdoutLines.on('line', (line) => { + if (!printReadyLine(line)) { + console.log(line) + } +}) + +child.once('error', (error) => { + console.error(`[headless-pairing] failed to start server: ${error.message}`) + cleanupProfile() + process.exitCode = 1 +}) + +child.once('exit', (code, signal) => { + stdoutLines.close() + if (!sawPairingUrl && code !== 0) { + console.error('[headless-pairing] server exited before printing a pairing URL.') + } + cleanupProfile() + if (typeof code === 'number') { + process.exitCode = code + return + } + process.exitCode = signal ? 1 : 0 +}) + +process.on('SIGINT', () => stopChild('SIGINT')) +process.on('SIGTERM', () => stopChild('SIGTERM')) + +/** + * Parses wrapper flags and forwards everything else to `orca serve`. + */ +function parseArgs(args) { + const serveArgs = [] + let keepProfile = false + let help = false + for (const arg of args) { + if (arg === '--keep') { + keepProfile = true + continue + } + if (arg === '-h' || arg === '--help') { + help = true + continue + } + serveArgs.push(arg) + } + return { help, keepProfile, serveArgs } +} + +/** + * Prints script usage without touching the dev profile or starting the server. + */ +function printHelp() { + console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags] + +Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL. + +Wrapper flags: + --keep Keep the fresh profile after the server exits. + -h, --help Show this help. + +Forwarded examples: + node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing + +Environment: + ORCA_HEADLESS_PAIRING_ADDRESS= Override the auto pairing address. + ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory. +`) +} + +/** + * Adds a reachable pairing address unless the caller provided one explicitly. + */ +function withDefaultPairingAddress(args) { + if (hasPairingAddress(args)) { + return args + } + const address = resolveDefaultPairingAddress() + if (!address) { + return args + } + console.error(`[headless-pairing] pairingAddress=${address} (auto)`) + return [...args, '--pairing-address', address] +} + +/** + * Checks both supported CLI forms for an explicit pairing address. + */ +function hasPairingAddress(args) { + return hasForwardedServeFlag(args, 'pairing-address') +} + +/** + * Checks both exact and `--flag=value` forms for a forwarded serve flag. + */ +function hasForwardedServeFlag(args, name) { + const flag = `--${name}` + return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) +} + +/** + * Prefers an override, then Tailscale, then the OS hostname over loopback. + */ +function resolveDefaultPairingAddress() { + const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim() + if (configured) { + return configured + } + const tailscaleAddress = readTailscaleAddress() + if (tailscaleAddress) { + return tailscaleAddress + } + const host = hostname().trim() + return host && host !== 'localhost' ? host : null +} + +/** + * Reads the first Tailscale IPv4 address when Tailscale is installed. + */ +function readTailscaleAddress() { + const result = spawnSync('tailscale', ['ip', '-4'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + if (result.error || result.status !== 0) { + return null + } + return ( + result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +/** + * Formats forwarded arguments for the startup log. + */ +function formatForwardedArgs(args) { + if (args.length === 0) { + return '' + } + return ` ${args.map(formatShellArg).join(' ')}` +} + +/** + * Quotes a shell-ish argument for display only. + */ +function formatShellArg(value) { + if (/^[\w./:=@+-]+$/.test(value)) { + return value + } + return JSON.stringify(value) +} + +/** + * Runs the same Electron runtime preflight used by dev/start package scripts. + */ +function ensureElectronRuntime() { + console.error('[headless-pairing] ensuring Electron runtime...') + const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], { + cwd: repoRoot, + stdio: 'inherit' + }) + if (result.error) { + console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`) + process.exit(1) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +/** + * Rewrites the CLI JSON readiness line into the pairing URL testers need. + */ +function printReadyLine(line) { + let payload + try { + payload = JSON.parse(line) + } catch { + return false + } + if (!payload || payload.type !== 'orca_server_ready') { + return false + } + console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`) + if (payload.pairing?.endpoint) { + console.log(`Pairing endpoint: ${payload.pairing.endpoint}`) + } + if (payload.pairing?.webClientUrl) { + console.log(`Web client URL: ${payload.pairing.webClientUrl}`) + } + if (payload.pairing?.url) { + sawPairingUrl = true + console.log(`Pairing URL: ${payload.pairing.url}`) + console.error('[headless-pairing] server is running; press Ctrl+C to stop.') + return true + } + console.log('Pairing URL: unavailable') + return true +} + +/** + * Stops the foreground server; repeated signals force its process tree down. + */ +function stopChild(signal) { + if (!child || child.killed) { + return + } + stopAttempts += 1 + const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal + if (process.platform === 'win32' && child.pid) { + // Why: child.kill() only targets orca-dev on Windows; taskkill walks the + // CLI/Electron descendants so the fresh profile is not left locked. + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.on('error', () => child?.kill(targetSignal)) + return + } + if (process.platform !== 'win32' && child.pid) { + // Why: orca-dev synchronously owns the CLI child, which owns Electron; kill + // the spawned process group so programmatic shutdown does not orphan serve. + try { + process.kill(-child.pid, targetSignal) + return + } catch { + // Fall back to the direct child below if the process group already exited. + } + } + child.kill(targetSignal) +} + +/** + * Removes only the ephemeral profile directory this script created. + */ +function cleanupProfile() { + if (cleanedUp) { + return + } + cleanedUp = true + if (parsed.keepProfile || !ownsProfileDir) { + console.error(`[headless-pairing] kept ${profileDir}`) + return + } + if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) { + console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`) + return + } + try { + rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + console.error(`[headless-pairing] removed ${profileDir}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`) + } +} diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 73978e4f339..d4d6031b209 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -265,11 +265,87 @@ describe('resolveTabAgentFromSignals', () => { isRemote: false, title: '✳ Claude Code', hookAgent: null, + launchAgent: 'codex' }) ).toBe('codex') }) + it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal 1', + hookAgent: null, + siblingHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: 'omp', + launchAgent: 'pi' + }) + ).toBe('pi') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + siblingCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + }) + it('prefers explicit hook identity over a conflicting title mention', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 40bf92d6f7b..e6f992fe74f 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -11,6 +11,7 @@ import { resolveSiblingTabAgent } from './tab-agent' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' import type { TerminalTab, TuiAgent } from '../../../shared/types' // A shell name, or the tab's neutral default title — where Orca's @@ -20,6 +21,19 @@ function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) } +/** + * Resolves wrapper-compatible signal identity against the launch owner. + */ +function resolveSignalAgentForLaunchOwner( + signalAgent: TuiAgent | null | undefined, + launchAgent: TuiAgent | null +): TuiAgent | null { + if (!signalAgent) { + return null + } + return (resolveCompatibleAgentTypeForOwner(signalAgent, launchAgent) ?? signalAgent) as TuiAgent +} + /** * Probe-free evidence that a launched agent exited: the title shows no agent, * no live hook row remains in the tab, and either the hook completed or @@ -68,11 +82,13 @@ export function resolveTabAgentFromSignals(args: { launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null - const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(args.title) - // Why: when a pane is reused for a different agent, its launchAgent goes stale. - // A live title that explicitly names a *different* agent, once the pane has - // shown any activity, overrides that stale launch identity so the tab icon - // tracks what is actually running (codex launch reused for claude, etc.). + const explicitTitleAgent = resolveSignalAgentForLaunchOwner( + resolveExplicitTerminalTitleAgentType(args.title), + launchAgent + ) + // Why: explicit titles can override stale launches after activity, but + // Pi-compatible wrapper signals first resolve through the launch owner so + // OMP-created sessions do not repaint as Pi. // Why: OSC 133;D proved this local pane's foreground is back at the shell, // so any title-derived identity is stale by definition — a TUI that died // with a stuck title must not keep painting the tab through the title layer. @@ -96,16 +112,20 @@ export function resolveTabAgentFromSignals(args: { const completedHookAgent = !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook ? null - : (args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent ?? null) - const focusedHookAgent = args.hookAgent ?? null - const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null + : resolveSignalAgentForLaunchOwner( + args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent, + launchAgent + ) + const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent) + const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) + const fallbackHookAgent = siblingHookAgent ?? completedHookAgent const launchedAgentExited = resolveLaunchedAgentExitEvidence({ title: args.title, defaultTitle: args.defaultTitle, isRemote: args.isRemote, hasObservedAgentSignal: args.hasObservedAgentSignal, hookAgent: focusedHookAgent, - siblingHookAgent: args.siblingHookAgent, + siblingHookAgent, hasCompletedHook, processAgent: args.processAgent, processShellForeground: args.processShellForeground From 5790793649ce4a142694b694d9806eb4aa7796a2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:21:23 -0700 Subject: [PATCH 034/152] fix(agent-hooks): install Droid & Copilot managed hooks over SSH (#7253) (#7744) Co-authored-by: Orca --- .../remote-hook-service-installers.test.ts | 124 +++++++++++++++++ .../remote-managed-hook-installers.ts | 10 ++ src/main/droid/hook-service.ts | 130 +++++++++++++----- 3 files changed, 229 insertions(+), 35 deletions(-) diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 8a85128cdef..550af50bc42 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -8,6 +8,7 @@ vi.mock('electron', () => ({ })) import { CodexHookService } from '../codex/hook-service' +import { DroidHookService } from '../droid/hook-service' import { CursorHookService } from '../cursor/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { GeminiHookService } from '../gemini/hook-service' @@ -20,6 +21,24 @@ import { HermesHookService } from '../hermes/hook-service' import { DevinHookService } from '../devin/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { ampHookService } from '../amp/hook-service' +import { antigravityHookService } from '../antigravity/hook-service' +import { claudeHookService } from '../claude/hook-service' +import { codexHookService } from '../codex/hook-service' +import { copilotHookService } from '../copilot/hook-service' +import { cursorHookService } from '../cursor/hook-service' +import { droidHookService } from '../droid/hook-service' +import { commandCodeHookService } from '../command-code/hook-service' +import { geminiHookService } from '../gemini/hook-service' +import { devinHookService } from '../devin/hook-service' +import { grokHookService } from '../grok/hook-service' +import { hermesHookService } from '../hermes/hook-service' +import { kimiHookService } from '../kimi/hook-service' +import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' type FakeFs = { files: Map @@ -166,6 +185,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/devin-hook.sh', install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/droid-hook.sh', + install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev') } ] @@ -582,6 +605,107 @@ describe('remote hook service installers', () => { expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755) }) + // Why: Droid (and Copilot) each shipped a working installRemote but were never + // registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never + // appeared over SSH (issue #7253). Guard the whole bug class, not one agent: + // every locally-managed hook service that implements installRemote MUST be + // wired into the remote installer. + it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => { + const servicesByAgent = new Map([ + ['claude', claudeHookService], + ['openclaude', openClaudeHookService], + ['codex', codexHookService], + ['gemini', geminiHookService], + ['antigravity', antigravityHookService], + ['amp', ampHookService], + ['cursor', cursorHookService], + ['droid', droidHookService], + ['command-code', commandCodeHookService], + ['grok', grokHookService], + ['copilot', copilotHookService], + ['hermes', hermesHookService], + ['devin', devinHookService], + ['kimi', kimiHookService] + ]) + + // Guard against a service silently missing from the map above as new agents land. + for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) { + expect(servicesByAgent.has(agent)).toBe(true) + } + + const registered = new Set(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS) + const missing: string[] = [] + for (const [agent, service] of servicesByAgent) { + if (typeof service.installRemote === 'function' && !registered.has(agent)) { + missing.push(agent) + } + } + expect(missing).toEqual([]) + }) + + it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { + const { sftp } = createFakeSftp() + const results = await installRemoteManagedAgentHooks(sftp, '/home/dev') + const byAgent = new Map(results.map((r) => [r.agent, r.state])) + expect(byAgent.get('droid')).toBe('installed') + expect(byAgent.get('copilot')).toBe('installed') + }) + + it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new DroidHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('installed') + expect(status.configPath).toBe('/home/dev/.factory/settings.json') + const config = JSON.parse(fs.files.get('/home/dev/.factory/settings.json')!) as { + hooks: Record + } + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreToolUse', + 'PostToolUse', + 'PermissionRequest', + 'Notification' + ]) { + const definition = config.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + // Tool/permission events carry a `*` matcher; lifecycle events do not. + expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*') + expect(config.hooks.PostToolUse?.[0]?.matcher).toBe('*') + expect(config.hooks.PermissionRequest?.[0]?.matcher).toBe('*') + expect(config.hooks.Stop?.[0]?.matcher).toBeUndefined() + const script = fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh') + expect(script).toContain('#!/bin/sh') + expect(script).toContain('/hook/droid') + expect(fs.modes.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBe(0o755) + }) + + it('does not overwrite a malformed remote Factory settings.json', async () => { + const original = '{"hooks": }' + const { sftp, fs } = createFakeSftp({ + '/home/dev/.factory/settings.json': original + }) + + const status = await new DroidHookService().installRemote(sftp, '/home/dev') + + expect(status).toMatchObject({ + agent: 'droid', + state: 'error', + configPath: '/home/dev/.factory/settings.json', + managedHooksPresent: false, + detail: 'Could not parse remote Factory settings.json' + }) + expect(fs.files.get('/home/dev/.factory/settings.json')).toBe(original) + expect(fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBeUndefined() + }) + it('installs remote Hermes plugin files and enables the plugin', async () => { const { sftp, fs } = createFakeSftp() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 7913b863577..e68ec2a8dcb 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -7,7 +7,9 @@ import { geminiHookService } from '../gemini/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { cursorHookService } from '../cursor/hook-service' import { commandCodeHookService } from '../command-code/hook-service' +import { copilotHookService } from '../copilot/hook-service' import { devinHookService } from '../devin/hook-service' +import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' @@ -27,12 +29,20 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], + ['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], + ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] ] +/** Agents wired into the remote (SSH) hook installer. Exported so an invariant + * test can assert every locally-managed agent that implements `installRemote` + * is registered here — the omission that hid Droid/Copilot status over SSH. */ +export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStatus['agent'][] = + REMOTE_MANAGED_HOOK_INSTALLERS.map(([agent]) => agent) + export async function installRemoteManagedAgentHooks( sftp: SFTPWrapper, remoteHome: string diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 920e079989d..946f5ac8809 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -1,5 +1,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildManagedCommandHook, @@ -12,8 +13,14 @@ import { wrapWindowsHookCommand, writeHooksJson, writeManagedScript, - type HookDefinition + type HookDefinition, + type HooksConfig } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' // Why: SessionStart is installed (not just listened for) so that resuming a // droid session via `droid --resume` resets the per-pane prompt/tool caches @@ -64,8 +71,8 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -111,6 +118,45 @@ function getManagedScript(): string { ].join('\n') } +// Why: local install() and installRemote() must produce byte-identical Factory +// settings.json hook trees; sharing this mutation is what keeps the SSH install +// from drifting off the local one (the exact class of bug that left Droid with +// no remote installer at all — issue #7253). +function buildInstalledDroidConfig( + config: HooksConfig, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(DROID_EVENTS.map((event) => event.eventName)) + + // Why: sweep managed entries out of events we no longer subscribe to. + 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 + } + } + + for (const event of DROID_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [buildManagedCommandHook(command)] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} + export class DroidHookService { getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() @@ -189,43 +235,57 @@ export class DroidHookService { } } - const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - const managedEvents = new Set(DROID_EVENTS.map((event) => event.eventName)) - - // Why: sweep managed entries out of events we no longer subscribe to. - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (managedEvents.has(eventName)) { - continue - } - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } - } - - for (const event of DROID_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [buildManagedCommandHook(command)] - } - nextHooks[event.eventName] = [...cleaned, definition] - } - - config.hooks = nextHooks + buildInstalledDroidConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) writeManagedScript(scriptPath, getManagedScript()) writeHooksJson(configPath, config) return this.getStatus() } + // Why: SSH remotes run the Droid CLI on the remote host, so its hook config + // and managed script must be written into the remote ~/.factory + ~/.orca via + // SFTP. Without this, Droid never fires the managed hook over SSH and its + // status row is absent from the task tree (issue #7253). Mirrors the local + // install() but always emits POSIX script/paths — even from a Windows host. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.factory/settings.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/droid-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'droid', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Factory settings.json' + } + } + + buildInstalledDroidConfig(config, wrapPosixHookCommand(remoteScriptPath), 'droid-hook.sh') + // Why: script first, config last — a partial config write must never + // point Droid at a script that isn't on disk yet. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'droid', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'droid', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) From d9225c5ccdc5f0389a1139da59a1e593e74dbb2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:43:31 -0700 Subject: [PATCH 035/152] fix(github): route all PR mutations through the repo's owner host (#7739) Co-authored-by: Orca --- .../src/components/GitHubItemDialog.tsx | 61 ++++++------ .../src/components/PullRequestPage.tsx | 49 ++++++---- .../github-item-dialog-merge-routing.test.ts | 71 -------------- ...github-item-dialog-source-boundary.test.ts | 15 ++- .../pull-request-page-host-boundary.test.ts | 9 +- .../lib/github-source-runtime-context.test.ts | 91 ++++++++++++++++++ .../src/lib/github-source-runtime-context.ts | 21 ++++ .../src/lib/repo-runtime-owner.test.ts | 95 +++++++++++++++++++ src/renderer/src/lib/repo-runtime-owner.ts | 19 ++++ 9 files changed, 297 insertions(+), 134 deletions(-) delete mode 100644 src/renderer/src/components/github-item-dialog-merge-routing.test.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 5d45f1f78ea..fd1eb44a8d7 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -166,6 +166,7 @@ import { import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, + getGitHubMutationRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' @@ -3848,21 +3849,11 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null)) + const sourceSettings = useAppStore( + useShallow((s) => + getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) + ) ) - const sourceSettings = useMemo(() => { - if (sourceContext?.provider !== 'github') { - return repoOwnerSettings - } - const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) - return sourceRuntimeSettings.activeRuntimeEnvironmentId - ? ({ - ...repoOwnerSettings, - ...sourceRuntimeSettings - } as typeof repoOwnerSettings) - : repoOwnerSettings - }, [repoOwnerSettings, sourceContext]) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' @@ -5465,32 +5456,36 @@ async function runPullRequestStateUpdate(args: { } return } - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (!args.repoPath && !runtimeHost) { + // Why: close/reopen must route by the repo owner host like merge (#6957). + const target = getActiveRuntimeTarget( + getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) + ) + if (!args.repoPath && target.kind !== 'environment') { throw new Error('No repo context available for this pull request.') } - const res = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.updatePRState', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.updatePRState', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + prNumber: args.number, + updates: args.updates + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRState({ + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, prNumber: args.number, updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePRState({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - prNumber: args.number, - updates: args.updates - }) + }) if (!res.ok) { throw new Error(res.error) } - if (runtimeHost) { + if (target.kind === 'environment') { notifyWorkItemDetailsMutation( { repoPath: args.repoPath ?? '', diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index aa1a23707e6..9114000a436 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -169,6 +169,7 @@ import { import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, + getGitHubMutationRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' @@ -3841,7 +3842,11 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = getTaskSourceRuntimeSettings(sourceContext) + const sourceSettings = useAppStore( + useShallow((s) => + getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) + ) + ) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' @@ -5731,32 +5736,36 @@ async function runPullRequestStateUpdate(args: { } return } - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (!args.repoPath && !runtimeHost) { + // Why: close/reopen must route by the repo owner host like merge (#6957). + const target = getActiveRuntimeTarget( + getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) + ) + if (!args.repoPath && target.kind !== 'environment') { throw new Error('No repo context available for this pull request.') } - const res = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.updatePRState', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.updatePRState', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + prNumber: args.number, + updates: args.updates + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRState({ + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, prNumber: args.number, updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePRState({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - prNumber: args.number, - updates: args.updates - }) + }) if (!res.ok) { throw new Error(res.error) } - if (runtimeHost) { + if (target.kind === 'environment') { notifyWorkItemDetailsMutation( { repoPath: args.repoPath ?? '', diff --git a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts deleted file mode 100644 index 8374fe94ba9..00000000000 --- a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getSettingsForRepoRuntimeOwner, - type RepoRuntimeOwnerState -} from '../lib/repo-runtime-owner' -import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' -import type { TaskSourceContext } from '../../../shared/task-source-context' -import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' - -// Why: mirrors the merge-routing decision inside GitHubItemDialog's -// PRActionsPanel so a revert to source-only routing (issue #6957) fails here. -// A runtime-owned repo whose GitHub source view is local must still merge on -// the owner runtime, not fall back to local `gh:mergePR`. -function resolveMergeTarget( - state: RepoRuntimeOwnerState, - repoId: string | null, - sourceContext: TaskSourceContext | null -): ReturnType { - const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId) - const sourceSettings = - sourceContext?.provider !== 'github' - ? repoOwnerSettings - : (() => { - const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) - return sourceRuntimeSettings.activeRuntimeEnvironmentId - ? { ...repoOwnerSettings, ...sourceRuntimeSettings } - : repoOwnerSettings - })() - return getActiveRuntimeTarget(sourceSettings) -} - -function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext { - return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' } -} - -describe('GitHubItemDialog PR merge routing', () => { - const runtimeOwnedRepo: RepoRuntimeOwnerState = { - settings: { activeRuntimeEnvironmentId: null }, - repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] - } - - it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { - expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({ - kind: 'environment', - environmentId: 'owner-runtime' - }) - }) - - it('routes a runtime-owned repo to its owner runtime when there is no source context', () => { - expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({ - kind: 'environment', - environmentId: 'owner-runtime' - }) - }) - - it('lets a runtime source override the repo owner when both are runtimes', () => { - expect( - resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime')) - ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) - }) - - it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => { - const localRepo: RepoRuntimeOwnerState = { - settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, - repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] - } - expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({ - kind: 'local' - }) - }) -}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 740d65f67cf..951e22d6651 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -67,6 +67,9 @@ describe('GitHubItemDialog source host boundaries', () => { expect(helperSection).toContain("'github.project.updatePullRequestBySlug'") expect(helperSection).toContain("args.sourceContext?.provider === 'github'") expect(helperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') + expect(helperSection).toContain( + 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' + ) expect(helperSection).toContain('notifyWorkItemDetailsMutation(') expect(helperSection).toContain( "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" @@ -91,8 +94,8 @@ describe('GitHubItemDialog source host boundaries', () => { expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toMatch( - /if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/ + expect(matchInvalidationSection).toContain( + 'if (removed) {\n workItemDetailsCacheGeneration += 1' ) }) @@ -182,7 +185,7 @@ describe('GitHubItemDialog source host boundaries', () => { expect(source).toContain('notifyWorkItemMutated({') }) - it('routes merge actions through the task source context', () => { + it('routes merge actions through the repo owner host (#6957)', () => { const source = componentSource('GitHubItemDialog.tsx') const actionsSection = sourceBetween( source, @@ -190,13 +193,9 @@ describe('GitHubItemDialog source host boundaries', () => { 'function CommentReactions' ) - expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId') - expect(actionsSection).toContain('...repoOwnerSettings') expect(actionsSection).toContain( - 'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)' + 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' ) - expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId') - expect(actionsSection).toContain('...sourceRuntimeSettings') expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') expect(actionsSection).toContain( 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' diff --git a/src/renderer/src/components/pull-request-page-host-boundary.test.ts b/src/renderer/src/components/pull-request-page-host-boundary.test.ts index 888225aa350..89d0dc14f57 100644 --- a/src/renderer/src/components/pull-request-page-host-boundary.test.ts +++ b/src/renderer/src/components/pull-request-page-host-boundary.test.ts @@ -270,6 +270,9 @@ describe('PullRequestPage host boundaries', () => { expect(editHelperSection).toContain('sourceContext?: TaskSourceContext | null') expect(editHelperSection).toContain("args.sourceContext?.provider === 'github'") expect(editHelperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') + expect(editHelperSection).toContain( + 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' + ) expect(editHelperSection).toContain( "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" ) @@ -278,7 +281,7 @@ describe('PullRequestPage host boundaries', () => { expect(editSection).toContain('sourceContext,') }) - it('routes merge actions through the PR source context', () => { + it('routes merge actions through the repo owner host (#6957)', () => { const source = componentSource('PullRequestPage.tsx') const actionsSection = sourceBetween( source, @@ -286,7 +289,9 @@ describe('PullRequestPage host boundaries', () => { 'function CommentReactions' ) - expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(actionsSection).toContain( + 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' + ) expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') expect(actionsSection).toContain( 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' diff --git a/src/renderer/src/lib/github-source-runtime-context.test.ts b/src/renderer/src/lib/github-source-runtime-context.test.ts index 7aee8772ec7..b0cc6c505ab 100644 --- a/src/renderer/src/lib/github-source-runtime-context.test.ts +++ b/src/renderer/src/lib/github-source-runtime-context.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' import { canUseGitHubRepoContext, + getGitHubMutationRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost, getGitHubSourceRuntimeTarget } from './github-source-runtime-context' +import type { RepoRuntimeOwnerState } from './repo-runtime-owner' const runtimeSourceContext: TaskSourceContext = { kind: 'task-source', @@ -52,3 +55,91 @@ describe('GitHub source runtime context', () => { ).toBe('fallback') }) }) + +describe('getGitHubMutationRoutingSettings', () => { + const runtimeOwnedRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + } + + function resolveTarget( + state: RepoRuntimeOwnerState, + repoId: string | null, + sourceContext: TaskSourceContext | null + ): ReturnType { + return getActiveRuntimeTarget(getGitHubMutationRoutingSettings(state, repoId, sourceContext)) + } + + it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { + expect( + resolveTarget(runtimeOwnedRepo, 'repo-1', { + ...runtimeSourceContext, + hostId: 'local', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'environment', environmentId: 'owner-runtime' }) + }) + + it('routes a runtime-owned repo to its owner runtime when there is no source context', () => { + expect(resolveTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('lets a runtime source override the repo owner when both name runtimes', () => { + expect( + resolveTarget(runtimeOwnedRepo, 'repo-1', { + ...runtimeSourceContext, + hostId: 'runtime:source-runtime', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) + }) + + it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => { + const localRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + } + expect( + resolveTarget(localRepo, 'repo-1', { + ...runtimeSourceContext, + hostId: 'local', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'local' }) + }) + + it('ignores runtime hosts on non-GitHub sources', () => { + expect( + resolveTarget(runtimeOwnedRepo, 'repo-1', { + ...runtimeSourceContext, + provider: 'gitlab', + hostId: 'runtime:source-runtime' + }) + ).toEqual({ kind: 'environment', environmentId: 'owner-runtime' }) + }) + + it('never routes a repo without an explicit owner to the globally focused runtime', () => { + const noOwnerRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }] + } + expect( + resolveTarget(noOwnerRepo, 'repo-1', { + ...runtimeSourceContext, + hostId: 'local', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'local' }) + expect(resolveTarget(noOwnerRepo, 'repo-1', null)).toEqual({ kind: 'local' }) + expect( + resolveTarget(noOwnerRepo, 'repo-1', { + ...runtimeSourceContext, + hostId: 'runtime:source-runtime', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) + }) +}) diff --git a/src/renderer/src/lib/github-source-runtime-context.ts b/src/renderer/src/lib/github-source-runtime-context.ts index 61797033555..c36598e3420 100644 --- a/src/renderer/src/lib/github-source-runtime-context.ts +++ b/src/renderer/src/lib/github-source-runtime-context.ts @@ -2,8 +2,13 @@ import type { ParsedExecutionHost } from '../../../shared/execution-host' import { parseExecutionHostId } from '../../../shared/execution-host' import type { TaskSourceContext } from '../../../shared/task-source-context' import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' +import type { GlobalSettings } from '../../../shared/types' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getExplicitRuntimeOwnerEnvironmentId, + type RepoRuntimeOwnerState +} from './repo-runtime-owner' export type GitHubRuntimeHost = Extract @@ -25,6 +30,22 @@ export function getGitHubSourceRuntimeTarget( ) } +// Why: PR mutations must run on the repo's explicit owner host (#6957); a +// local or absent source never downgrades a runtime-owned repo to local IPC, +// a runtime source still overrides, and the globally focused runtime is never +// used as a fallback — a repo without an explicit owner is a local repo. +export function getGitHubMutationRoutingSettings( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined, + sourceContext: TaskSourceContext | null | undefined +): Pick { + const sourceHost = getGitHubSourceRuntimeHost(sourceContext) + return { + activeRuntimeEnvironmentId: + sourceHost?.environmentId ?? getExplicitRuntimeOwnerEnvironmentId(state, repoId) + } +} + export function canUseGitHubRepoContext( repoPath: string | null | undefined, sourceContext: TaskSourceContext | null | undefined diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index 719c964676e..dcc78094e43 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { GlobalSettings } from '../../../shared/types' import { + getExplicitRuntimeOwnerEnvironmentId, getRepoOwnerRoutedSettings, getRuntimeEnvironmentIdForRepo, getSettingsForRepoRuntimeOwner @@ -98,6 +99,100 @@ describe('getRuntimeEnvironmentIdForRepo', () => { }) }) +describe('getExplicitRuntimeOwnerEnvironmentId', () => { + it('returns the runtime env id for a repo with an explicit runtime owner', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('returns null for an explicit local owner even while a runtime is focused', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }, + 'repo-1' + ) + ).toBeNull() + }) + + it('returns null for an SSH-owned repo (connectionId only)', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: null }] + }, + 'repo-1' + ) + ).toBeNull() + }) + + // Why: unlike getRuntimeEnvironmentIdForRepo, a no-owner repo must NOT fall + // back to the focused runtime — an owner-less repo is a local repo (#6957). + it('returns null for a legacy repo without an owner instead of the focused runtime', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }] + }, + 'repo-1' + ) + ).toBeNull() + }) + + it('routes duplicate repo ids to the runtime row that focus selects unambiguously', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('returns null for ambiguous duplicate repo ids when focus matches no row', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'other-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBeNull() + }) + + it('returns null when the repo id is missing', () => { + expect( + getExplicitRuntimeOwnerEnvironmentId( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + }, + null + ) + ).toBeNull() + }) +}) + describe('getRepoOwnerRoutedSettings', () => { // Why: SourceControl builds its git/file mutation contexts from this value, // so it must rebind activeRuntimeEnvironmentId to the repo OWNER even while a diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 36b7cf757d2..0ccfb2d09fe 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -46,6 +46,25 @@ export function getRuntimeEnvironmentIdForRepo( return state.settings?.activeRuntimeEnvironmentId?.trim() || null } +// Why: PR mutations must not fall back to the globally focused runtime — a +// repo without an explicit owner is a local repo, and routing it to the +// focused runtime sends the mutation to a host that does not own it (#6957). +export function getExplicitRuntimeOwnerEnvironmentId( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): string | null { + if (!repoId) { + return null + } + const repo = findRepoOwner(state, repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (!repo || !hasExplicitOwner) { + return null + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null +} + export function getSettingsForRepoRuntimeOwner( state: RepoRuntimeOwnerState, repoId: string | null | undefined From 9e03e28d3757cd23382ef1773047673f92e7fd0b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:50:44 -0700 Subject: [PATCH 036/152] Stop unreachable-runtime request storms (negative cache, stable hook deps, subscribe backoff) (#7743) Co-authored-by: Orca --- .../src/hooks/metadata-request-cache.test.ts | 75 ++++++- .../src/hooks/metadata-request-cache.ts | 55 +++++ .../hooks/runtime-client-events-sync.test.ts | 211 +++++++++++++++++- .../src/hooks/runtime-client-events-sync.ts | 37 ++- .../src/hooks/useGitHubSlugMetadata.test.tsx | 52 +++++ .../src/hooks/useGitHubSlugMetadata.ts | 18 +- .../src/hooks/useIssueMetadata.test.tsx | 153 +++++++++++++ src/renderer/src/hooks/useIssueMetadata.ts | 41 +++- 8 files changed, 619 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/hooks/useIssueMetadata.test.tsx diff --git a/src/renderer/src/hooks/metadata-request-cache.test.ts b/src/renderer/src/hooks/metadata-request-cache.test.ts index 89644e5a832..7770aca9637 100644 --- a/src/renderer/src/hooks/metadata-request-cache.test.ts +++ b/src/renderer/src/hooks/metadata-request-cache.test.ts @@ -49,19 +49,88 @@ describe('metadata-request-cache', () => { expect(getFreshMetadata(store, 'repo-b:labels')?.data).toEqual(['b']) }) - it('does not cache failed requests', async () => { + it('paces failed requests with a short negative TTL instead of refetching immediately', async () => { const store = createMetadataRequestStore() const fetcher = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error('network')) .mockResolvedValueOnce(['triage']) - await expect(loadMetadata(store, 'repo:labels', fetcher)).rejects.toThrow('network') - await expect(loadMetadata(store, 'repo:labels', fetcher)).resolves.toEqual(['triage']) + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 1_000)).rejects.toThrow( + 'network' + ) + // Within the failure TTL the cached rejection is reused without a fetch. + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 2_000)).rejects.toThrow( + 'network' + ) + expect(fetcher).toHaveBeenCalledTimes(1) + // Past the failure TTL the key becomes fetchable again. + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 11_000)).resolves.toEqual([ + 'triage' + ]) expect(fetcher).toHaveBeenCalledTimes(2) }) + it('a success clears the remembered failure for its key', async () => { + const store = createMetadataRequestStore() + const fetcher = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce(['triage']) + + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 1_000)).rejects.toThrow( + 'network' + ) + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 12_000)).resolves.toEqual([ + 'triage' + ]) + expect(store.failures.has('repo:labels')).toBe(false) + }) + + it('keeps failure entries isolated per key and bounded', async () => { + const store = createMetadataRequestStore() + await expect( + loadMetadata( + store, + 'repo-a:labels', + () => Promise.reject(new Error('down')), + () => 1_000 + ) + ).rejects.toThrow('down') + + const fetcherB = vi.fn(() => Promise.resolve(['ok'])) + await expect(loadMetadata(store, 'repo-b:labels', fetcherB, () => 1_000)).resolves.toEqual([ + 'ok' + ]) + expect(fetcherB).toHaveBeenCalledTimes(1) + }) + + it('does not record failures from a cleared generation', async () => { + const store = createMetadataRequestStore() + let rejectRequest: (error: Error) => void = () => {} + const pending = loadMetadata( + store, + 'repo:labels', + () => + new Promise((_resolve, reject) => { + rejectRequest = reject + }), + () => 1_000 + ) + + clearMetadataRequestStore(store) + rejectRequest(new Error('stale failure')) + await expect(pending).rejects.toThrow('stale failure') + expect(store.failures.size).toBe(0) + + const fetcher = vi.fn(() => Promise.resolve(['fresh'])) + await expect(loadMetadata(store, 'repo:labels', fetcher, () => 1_500)).resolves.toEqual([ + 'fresh' + ]) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + it('does not let stale in-flight responses repopulate after clear', async () => { const store = createMetadataRequestStore() let resolveRequest: (value: string[]) => void = () => {} diff --git a/src/renderer/src/hooks/metadata-request-cache.ts b/src/renderer/src/hooks/metadata-request-cache.ts index 20df229f0de..033f5e984c3 100644 --- a/src/renderer/src/hooks/metadata-request-cache.ts +++ b/src/renderer/src/hooks/metadata-request-cache.ts @@ -1,11 +1,19 @@ const METADATA_TTL = 300_000 // 5 min const MAX_METADATA_CACHE_ENTRIES = 500 +// Why: an unreachable provider/runtime fails every consumer render; without a +// negative cache each settlement re-issues the fetch, which storms a dead +// remote. Failures are remembered briefly so retries are paced, not disabled — +// short enough that a recovered provider is picked up within seconds. +const METADATA_FAILURE_TTL = 10_000 +const MAX_METADATA_FAILURE_ENTRIES = 200 type CachedMetadata = { data: T; fetchedAt: number } +type CachedMetadataFailure = { error: unknown; failedAt: number } export type MetadataRequestStore = { cache: Map> inflight: Map> + failures: Map generation: number } @@ -13,6 +21,7 @@ export function createMetadataRequestStore(): MetadataRequestStore { return { cache: new Map(), inflight: new Map(), + failures: new Map(), generation: 0 } } @@ -21,6 +30,7 @@ export function clearMetadataRequestStore(store: MetadataRequestStore): vo store.generation += 1 store.cache.clear() store.inflight.clear() + store.failures.clear() } function pruneMetadataCache( @@ -55,6 +65,37 @@ export function getFreshMetadata( return entry } +function pruneMetadataFailures( + store: MetadataRequestStore, + now: number, + maxEntries = MAX_METADATA_FAILURE_ENTRIES +): void { + for (const [key, entry] of store.failures) { + if (now - entry.failedAt >= METADATA_FAILURE_TTL) { + store.failures.delete(key) + } + } + if (store.failures.size <= maxEntries) { + return + } + const sorted = [...store.failures.entries()].sort((a, b) => b[1].failedAt - a[1].failedAt) + for (const [key] of sorted.slice(maxEntries)) { + store.failures.delete(key) + } +} + +export function getRecentMetadataFailure( + store: MetadataRequestStore, + key: string, + now = Date.now() +): CachedMetadataFailure | null { + const entry = store.failures.get(key) + if (!entry || now - entry.failedAt >= METADATA_FAILURE_TTL) { + return null + } + return entry +} + export function loadMetadata( store: MetadataRequestStore, key: string, @@ -71,6 +112,11 @@ export function loadMetadata( return inflight } + const recentFailure = getRecentMetadataFailure(store, key, now()) + if (recentFailure) { + return Promise.reject(recentFailure.error) + } + // Why: clearMetadataRequestStore invalidates auth/repo boundaries; late // responses from the previous generation must not repopulate the cache. const generation = store.generation @@ -79,6 +125,7 @@ export function loadMetadata( if (store.generation === generation) { const fetchedAt = now() store.cache.set(key, { data, fetchedAt }) + store.failures.delete(key) // Why: these module-level stores are reused across dialogs and // repo/runtime keys; TTL controls freshness but also needs pruning so // long sessions do not retain stale metadata indefinitely. @@ -86,6 +133,14 @@ export function loadMetadata( } return data }) + .catch((error: unknown) => { + if (store.generation === generation) { + const failedAt = now() + store.failures.set(key, { error, failedAt }) + pruneMetadataFailures(store, failedAt) + } + throw error + }) .finally(() => { if (store.inflight.get(key) === promise) { store.inflight.delete(key) diff --git a/src/renderer/src/hooks/runtime-client-events-sync.test.ts b/src/renderer/src/hooks/runtime-client-events-sync.test.ts index 3546b5b7bd6..c3ebb64cdb0 100644 --- a/src/renderer/src/hooks/runtime-client-events-sync.test.ts +++ b/src/renderer/src/hooks/runtime-client-events-sync.test.ts @@ -135,7 +135,8 @@ describe('createRuntimeClientEventsSync', () => { getDesiredEnvironmentIds: () => desired, subscribe, onEvent: vi.fn(), - retryDelayMs: 10 + retryDelayMs: 10, + random: () => 1 }) sync.sync() @@ -156,4 +157,212 @@ describe('createRuntimeClientEventsSync', () => { vi.useRealTimers() } }) + + it('backs off exponentially with a cap while an environment keeps failing', async () => { + vi.useFakeTimers() + try { + const subscribe = vi.fn( + (): Promise => + Promise.reject(new Error('unreachable')) + ) + const sync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => ['A'], + subscribe, + onEvent: vi.fn(), + retryDelayMs: 10, + retryMaxDelayMs: 40, + random: () => 1 + }) + + sync.sync() + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(1) + + // Attempt 2 after 10ms (base). + await vi.advanceTimersByTimeAsync(10) + expect(subscribe).toHaveBeenCalledTimes(2) + // Attempt 3 after 20ms more (doubled) — not before. + await vi.advanceTimersByTimeAsync(19) + expect(subscribe).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(subscribe).toHaveBeenCalledTimes(3) + // Attempt 4 after 40ms more (doubled again, hits cap). + await vi.advanceTimersByTimeAsync(40) + expect(subscribe).toHaveBeenCalledTimes(4) + // Attempt 5 stays at the 40ms cap. + await vi.advanceTimersByTimeAsync(39) + expect(subscribe).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1) + expect(subscribe).toHaveBeenCalledTimes(5) + } finally { + vi.useRealTimers() + } + }) + + it('applies jitter below the full backoff delay', async () => { + vi.useFakeTimers() + try { + const subscribe = vi.fn( + (): Promise => + Promise.reject(new Error('unreachable')) + ) + const sync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => ['A'], + subscribe, + onEvent: vi.fn(), + retryDelayMs: 10, + random: () => 0 + }) + + sync.sync() + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(1) + + // random() => 0 halves the delay: retry at 5ms instead of 10ms. + await vi.advanceTimersByTimeAsync(5) + expect(subscribe).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('starts a fresh backoff epoch after a successful subscribe', async () => { + vi.useFakeTimers() + try { + let desired = ['A'] + let failing = true + const unsubscribe = vi.fn() + const subscribe = vi.fn((): Promise => { + if (failing) { + return Promise.reject(new Error('unreachable')) + } + return Promise.resolve({ unsubscribe }) + }) + const sync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => desired, + subscribe, + onEvent: vi.fn(), + retryDelayMs: 10, + random: () => 1 + }) + + // Two failures escalate the delay to 20ms. + sync.sync() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10) + expect(subscribe).toHaveBeenCalledTimes(2) + + // Third attempt succeeds — failure count resets. + failing = false + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(3) + + // Env leaves and re-enters the desired set, then fails again: the first + // retry is back at the 10ms base, not the escalated delay. + desired = [] + sync.sync() + desired = ['A'] + failing = true + sync.sync() + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(9) + expect(subscribe).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1) + expect(subscribe).toHaveBeenCalledTimes(5) + } finally { + vi.useRealTimers() + } + }) + + it('an external sync retries a waiting environment immediately (recovery path)', async () => { + vi.useFakeTimers() + try { + const subscribe = vi.fn( + (): Promise => + Promise.reject(new Error('unreachable')) + ) + const sync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => ['A'], + subscribe, + onEvent: vi.fn(), + retryDelayMs: 10_000, + random: () => 1 + }) + + sync.sync() + // Let the rejection propagate through the then/catch chain so the retry + // timer is armed before the external sync fires. + await vi.advanceTimersByTimeAsync(0) + expect(subscribe).toHaveBeenCalledTimes(1) + + // A reachable-set transition calls sync() directly; the pending backoff + // timer must not delay this immediate re-attempt. + sync.sync() + await vi.advanceTimersByTimeAsync(0) + expect(subscribe).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('does not carry a stale failure count when a rejection lands after the env left the set', async () => { + vi.useFakeTimers() + try { + // 'B' stays pending forever so the desired set is never empty and the + // generation never bumps — this is what lets A's late rejection still be + // observed by its .catch (the exact leave-while-in-flight race). + let desired = ['A', 'B'] + const aRejecters: ((error: Error) => void)[] = [] + let aAttempts = 0 + const subscribe = vi.fn( + (environmentId: string): Promise => { + if (environmentId === 'B') { + return new Promise(() => {}) + } + aAttempts += 1 + return new Promise((_resolve, reject) => { + aRejecters.push(reject) + }) + } + ) + const sync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => desired, + subscribe, + onEvent: vi.fn(), + retryDelayMs: 10, + random: () => 1 + }) + + sync.sync() + expect(aAttempts).toBe(1) + + // A leaves the desired set while its subscribe is still in flight. + desired = ['B'] + sync.sync() + + // A's in-flight subscribe rejects now, after it is no longer desired. + aRejecters[0](new Error('unreachable')) + await vi.advanceTimersByTimeAsync(0) + + // A re-enters and its fresh subscribe (attempt 2) also fails. + desired = ['A', 'B'] + sync.sync() + await vi.advanceTimersByTimeAsync(0) + expect(aAttempts).toBe(2) + aRejecters[1](new Error('unreachable')) + await vi.advanceTimersByTimeAsync(0) + + // The first retry after re-entry must use the base delay (10ms). A stale + // count from the discarded rejection would have doubled it to 20ms. + await vi.advanceTimersByTimeAsync(9) + expect(aAttempts).toBe(2) + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + expect(aAttempts).toBe(3) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/renderer/src/hooks/runtime-client-events-sync.ts b/src/renderer/src/hooks/runtime-client-events-sync.ts index 128341dacbf..b23b6f01ed4 100644 --- a/src/renderer/src/hooks/runtime-client-events-sync.ts +++ b/src/renderer/src/hooks/runtime-client-events-sync.ts @@ -14,7 +14,11 @@ export type RuntimeClientEventsSyncDeps = { onError: (error: unknown) => void ) => Promise onEvent: (environmentId: string, event: RuntimeClientEvent) => void + /** Base retry delay; doubles per consecutive failure up to retryMaxDelayMs. */ retryDelayMs?: number + retryMaxDelayMs?: number + /** Injectable randomness for deterministic backoff-jitter tests. */ + random?: () => number } export type RuntimeClientEventsSync = { @@ -46,7 +50,10 @@ export function createRuntimeClientEventsSync( const subscriptions = new Map void>() const pending = new Set() const retryTimers = new Map>() + const consecutiveFailures = new Map() const retryDelayMs = deps.retryDelayMs ?? 1_000 + const retryMaxDelayMs = deps.retryMaxDelayMs ?? 30_000 + const random = deps.random ?? Math.random let generation = 0 const clearRetryTimer = (environmentId: string): void => { @@ -58,6 +65,17 @@ export function createRuntimeClientEventsSync( retryTimers.delete(environmentId) } + const nextRetryDelayMs = (environmentId: string): number => { + // Why: a flat retry hammers an unreachable runtime with one socket dial per + // tick forever. Exponential-with-cap keeps transient blips fast to recover + // while a dead host settles at one attempt per cap window; jitter keeps + // multiple envs from dialing in lockstep. External sync() (desired/reachable + // transitions) still retries immediately, so recovery is not delayed. + const failures = consecutiveFailures.get(environmentId) ?? 0 + const capped = Math.min(retryDelayMs * 2 ** Math.max(0, failures - 1), retryMaxDelayMs) + return capped * (0.5 + random() * 0.5) + } + const scheduleRetry = (environmentId: string, subscribeGeneration: number): void => { if (retryTimers.has(environmentId)) { return @@ -73,7 +91,7 @@ export function createRuntimeClientEventsSync( return } sync() - }, retryDelayMs) + }, nextRetryDelayMs(environmentId)) retryTimers.set(environmentId, retryTimer) } @@ -88,6 +106,7 @@ export function createRuntimeClientEventsSync( clearTimeout(retryTimer) } retryTimers.clear() + consecutiveFailures.clear() } const sync = (): void => { @@ -98,6 +117,11 @@ export function createRuntimeClientEventsSync( } clearRetryTimer(environmentId) } + for (const environmentId of consecutiveFailures.keys()) { + if (!desiredIds.has(environmentId)) { + consecutiveFailures.delete(environmentId) + } + } for (const [environmentId, unsubscribe] of subscriptions) { if (desiredIds.has(environmentId)) { @@ -139,14 +163,25 @@ export function createRuntimeClientEventsSync( subscription.unsubscribe() return } + consecutiveFailures.delete(environmentId) subscriptions.set(environmentId, subscription.unsubscribe) }) .catch((error) => { pending.delete(environmentId) if (subscribeGeneration === generation) { console.warn('[runtime-client-events] failed to subscribe:', error) + // Why: only track a failure when we will actually retry this env. + // A failure that lands after the env left the desired set must not + // leave a stale count that makes its first retry after re-entry skip + // the base delay. if (deps.getDesiredEnvironmentIds().includes(environmentId)) { + consecutiveFailures.set( + environmentId, + (consecutiveFailures.get(environmentId) ?? 0) + 1 + ) scheduleRetry(environmentId, subscribeGeneration) + } else { + consecutiveFailures.delete(environmentId) } } }) diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx index 4bec2a92d7b..4be71732d7c 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx @@ -122,4 +122,56 @@ describe('useGitHubSlugMetadata', () => { }) expect(renders).toBeLessThanOrEqual(4) }) + + it('does not re-issue a failed label fetch when a fresh settings object re-renders', async () => { + // Regression: with an unreachable provider, the failure setState re-rendered + // the consumer, the fresh settings identity re-armed the effect, and the hook + // re-issued the fetch each settlement — a render-paced request storm. + let renders = 0 + let error: string | null = null + apiMocks.listLabelsBySlug.mockRejectedValue(new Error('Could not connect')) + + function FailingLabelsProbe(): null { + renders += 1 + const metadata = useRepoLabelsBySlug('stablyai', 'orca', { + activeRuntimeEnvironmentId: null + }) + error = metadata.error + return null + } + + renderProbe() + await flushEffects() + // Extra settlement rounds give a storm the chance to manifest. + await flushEffects() + await flushEffects() + + expect(error).toBe('Could not connect') + expect(apiMocks.listLabelsBySlug).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not re-issue a failed assignee fetch when a fresh settings object re-renders', async () => { + let renders = 0 + let error: string | null = null + apiMocks.listAssignableUsersBySlug.mockRejectedValue(new Error('Could not connect')) + + function FailingAssigneesProbe(): null { + renders += 1 + const metadata = useRepoAssigneesBySlug('stablyai', 'orca', ['jinwoo'], { + activeRuntimeEnvironmentId: null + }) + error = metadata.error + return null + } + + renderProbe() + await flushEffects() + await flushEffects() + await flushEffects() + + expect(error).toBe('Could not connect') + expect(apiMocks.listAssignableUsersBySlug).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) }) diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 26022191fcc..4b3c0769640 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -44,12 +44,17 @@ export function useRepoLabelsBySlug( error: null }) const activeKeyRef = useRef(null) + // Why: parent selectors can pass a fresh settings object each render; keying + // the effect on the primitive env id keeps a failure's setState from re-running + // the effect and re-issuing the fetch in a render-paced loop (same class as + // the seedKey stabilization below). + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null useEffect(() => { if (!owner || !repo) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) const key = target.kind === 'environment' ? `runtime:${target.environmentId}:${owner}/${repo}` @@ -57,8 +62,6 @@ export function useRepoLabelsBySlug( const cached = getFreshMetadata(slugLabelStore, key) if (cached) { - // Why: parent selectors can pass a fresh settings object each render; - // only the first cached hit for this key should write React state. if (activeKeyRef.current !== key) { setState({ data: cached.data, loading: false, error: null }) } @@ -110,7 +113,7 @@ export function useRepoLabelsBySlug( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [owner, repo, settings]) + }, [owner, repo, activeRuntimeEnvironmentId]) return state } @@ -131,12 +134,15 @@ export function useRepoAssigneesBySlug( // the joined-string identity so the effect doesn't re-fire on every render // — this is the assignee popover refetch-storm fix. const seedKey = (seedLogins ?? []).slice().sort().join(',') + // Why: see useRepoLabelsBySlug — primitive env id keeps failure setState from + // re-arming the effect through a fresh settings object identity. + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null useEffect(() => { if (!owner || !repo) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) const key = target.kind === 'environment' ? `runtime:${target.environmentId}:${owner}/${repo}#${seedKey}` @@ -202,7 +208,7 @@ export function useRepoAssigneesBySlug( error: err instanceof Error ? err.message : 'Failed to load assignees' })) }) - }, [owner, repo, seedKey, settings]) + }, [owner, repo, seedKey, activeRuntimeEnvironmentId]) return state } diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx new file mode 100644 index 00000000000..e7e6dda6d8f --- /dev/null +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearLinearMetadataCache, + useTeamLabels, + useTeamMembers, + useTeamStates +} from './useIssueMetadata' + +const linearMocks = vi.hoisted(() => ({ + linearTeamStates: vi.fn(), + linearTeamLabels: vi.fn(), + linearTeamMembers: vi.fn() +})) + +vi.mock('@/runtime/runtime-linear-client', () => ({ + linearTeamStates: linearMocks.linearTeamStates, + linearTeamLabels: linearMocks.linearTeamLabels, + linearTeamMembers: linearMocks.linearTeamMembers +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(), + getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string | null } | null) => + settings?.activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +const roots: Root[] = [] + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function renderProbe(element: React.ReactNode): void { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render(element) + }) +} + +describe('useIssueMetadata Linear hooks', () => { + beforeEach(() => { + clearLinearMetadataCache() + linearMocks.linearTeamStates.mockReset() + linearMocks.linearTeamLabels.mockReset() + linearMocks.linearTeamMembers.mockReset() + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + }) + + it('does not loop when cached team-state metadata is read with a fresh settings object', async () => { + let renders = 0 + let states: unknown[] = [] + linearMocks.linearTeamStates.mockResolvedValue([{ id: 's1', name: 'Todo' }]) + + function StatesProbe(): null { + renders += 1 + // Fresh settings object each render — the storm trigger. + const metadata = useTeamStates('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + states = metadata.data + return null + } + + renderProbe() + await flushEffects() + + expect(states).toEqual([{ id: 's1', name: 'Todo' }]) + expect(linearMocks.linearTeamStates).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not re-issue a failed team-state fetch when a fresh settings object re-renders', async () => { + let renders = 0 + let error: string | null = null + linearMocks.linearTeamStates.mockRejectedValue(new Error('Could not connect')) + + function StatesProbe(): null { + renders += 1 + const metadata = useTeamStates('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + error = metadata.error + return null + } + + renderProbe() + await flushEffects() + await flushEffects() + await flushEffects() + + expect(error).toBe('Could not connect') + expect(linearMocks.linearTeamStates).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not re-issue a failed team-label fetch when a fresh settings object re-renders', async () => { + let renders = 0 + let error: string | null = null + linearMocks.linearTeamLabels.mockRejectedValue(new Error('Could not connect')) + + function LabelsProbe(): null { + renders += 1 + const metadata = useTeamLabels('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + error = metadata.error + return null + } + + renderProbe() + await flushEffects() + await flushEffects() + await flushEffects() + + expect(error).toBe('Could not connect') + expect(linearMocks.linearTeamLabels).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not re-issue a failed team-member fetch when a fresh settings object re-renders', async () => { + let renders = 0 + let error: string | null = null + linearMocks.linearTeamMembers.mockRejectedValue(new Error('Could not connect')) + + function MembersProbe(): null { + renders += 1 + const metadata = useTeamMembers('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + error = metadata.error + return null + } + + renderProbe() + await flushEffects() + await flushEffects() + await flushEffects() + + expect(error).toBe('Could not connect') + expect(linearMocks.linearTeamMembers).toHaveBeenCalledTimes(1) + expect(renders).toBeLessThanOrEqual(4) + }) +}) diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index ea1e306ca13..4bd89dbb635 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -231,13 +231,18 @@ export function useTeamStates( error: null }) const activeKeyRef = useRef(null) + // Why: parents can pass a fresh settings object each render; keying the effect + // on the derived cache key keeps a failure's setState from re-arming the fetch + // in a render-paced loop. The ref carries the latest settings for the call. + const settingsRef = useRef(settings) + settingsRef.current = settings + const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null useEffect(() => { - if (!teamId) { + if (!teamId || !cacheKey) { return } - const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearStateStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -256,7 +261,7 @@ export function useTeamStates( error: null })) loadMetadata(linearStateStore, cacheKey, () => - linearTeamStates(settings, teamId, workspaceId).then( + linearTeamStates(settingsRef.current, teamId, workspaceId).then( (states) => states as LinearWorkflowState[] ) ) @@ -277,7 +282,7 @@ export function useTeamStates( error: err instanceof Error ? err.message : 'Failed to load states' })) }) - }, [settings, teamId, workspaceId]) + }, [cacheKey, teamId, workspaceId]) return state } @@ -293,13 +298,17 @@ export function useTeamLabels( error: null }) const activeKeyRef = useRef(null) + // Why: see useTeamStates — cache-key deps + latest-settings ref stop the + // failure-setState render loop. + const settingsRef = useRef(settings) + settingsRef.current = settings + const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null useEffect(() => { - if (!teamId) { + if (!teamId || !cacheKey) { return } - const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearLabelStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -318,7 +327,9 @@ export function useTeamLabels( error: null })) loadMetadata(linearLabelStore, cacheKey, () => - linearTeamLabels(settings, teamId, workspaceId).then((labels) => labels as LinearLabel[]) + linearTeamLabels(settingsRef.current, teamId, workspaceId).then( + (labels) => labels as LinearLabel[] + ) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -337,7 +348,7 @@ export function useTeamLabels( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [settings, teamId, workspaceId]) + }, [cacheKey, teamId, workspaceId]) return state } @@ -353,13 +364,17 @@ export function useTeamMembers( error: null }) const activeKeyRef = useRef(null) + // Why: see useTeamStates — cache-key deps + latest-settings ref stop the + // failure-setState render loop. + const settingsRef = useRef(settings) + settingsRef.current = settings + const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null useEffect(() => { - if (!teamId) { + if (!teamId || !cacheKey) { return } - const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearMemberStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -378,7 +393,9 @@ export function useTeamMembers( error: null })) loadMetadata(linearMemberStore, cacheKey, () => - linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[]) + linearTeamMembers(settingsRef.current, teamId, workspaceId).then( + (members) => members as LinearMember[] + ) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -397,7 +414,7 @@ export function useTeamMembers( error: err instanceof Error ? err.message : 'Failed to load members' })) }) - }, [settings, teamId, workspaceId]) + }, [cacheKey, teamId, workspaceId]) return state } From 46dcfd33ea7e38dd8e636b872e9db76c711f10af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:53:56 -0700 Subject: [PATCH 037/152] Fix offline runtime worktree refresh call fanout (#7660) Co-authored-by: Orca --- .../src/runtime/runtime-rpc-client.test.ts | 170 ++++++++++++++++++ .../src/runtime/runtime-rpc-client.ts | 32 +++- .../src/store/slices/runtime-status.test.ts | 123 +++++++++++++ .../src/store/slices/runtime-status.ts | 18 +- .../src/store/slices/worktrees.test.ts | 98 ++++++++++ src/renderer/src/store/slices/worktrees.ts | 159 ++++++++++------ 6 files changed, 537 insertions(+), 63 deletions(-) diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 97280cb3562..515f8573586 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { callRuntimeRpc, assertRuntimeEnvironmentCapability, + clearRecentRuntimeCompatibilityFailure, clearRuntimeCompatibilityCacheForTests, getActiveRuntimeTarget, RuntimeRpcCallError, @@ -263,6 +264,175 @@ describe('runtime RPC client routing', () => { ]) }) + it('lets background compatibility checks reuse a recent foreground failure', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const target = { kind: 'environment', environmentId: 'env-offline' } as const + + await expect(callRuntimeRpc(target, 'git.status')).rejects.toThrow('offline') + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { + reuseRecentCompatibilityFailure: true + }) + ).rejects.toThrow('offline') + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual(['status.get']) + }) + + it('re-probes after a status success clears a recent compatibility failure', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-back-online' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + clearRecentRuntimeCompatibilityFailure('env-back-online') + await expect( + callRuntimeRpc(target, 'worktree.detectedList', undefined, { + reuseRecentCompatibilityFailure: true + }) + ).resolves.toEqual({ ok: true }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'status.get', + 'worktree.detectedList' + ]) + }) + + it('keeps a proven-compatible cache entry when clearing recent failures', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-still-ok' } as const + + await expect(callRuntimeRpc(target, 'repo.list')).resolves.toEqual({ ok: true }) + clearRecentRuntimeCompatibilityFailure('env-still-ok') + await expect(callRuntimeRpc(target, 'git.status')).resolves.toEqual({ ok: true }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'repo.list', + 'git.status' + ]) + }) + + it('re-probes when a status success clears a still-pending compatibility probe', async () => { + // Reconnect race: the offline probe stays queued on the dropped connection + // (pending, not yet failed) while a fresh status publish reports the host + // reachable. The clear must drop that doomed pending probe so the next + // reuse-flagged call starts a fresh probe instead of coalescing onto it. + let rejectFirstStatus!: (error: Error) => void + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return new Promise((_, reject) => { + rejectFirstStatus = reject + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-reconnect' } as const + + const pendingResult = callRuntimeRpc(target, 'repo.list', undefined, { + reuseRecentCompatibilityFailure: true + }).then( + () => 'resolved', + (error) => `rejected:${error.message}` + ) + // Let the first status.get register its in-flight cache entry. + await Promise.resolve() + + clearRecentRuntimeCompatibilityFailure('env-reconnect') + + const secondCall = callRuntimeRpc(target, 'worktree.detectedList', undefined, { + reuseRecentCompatibilityFailure: true + }) + await Promise.resolve() + // The doomed pending probe rejects; it must not fail the fresh re-probe. + rejectFirstStatus(new Error('stale connection closed')) + + await expect(secondCall).resolves.toEqual({ ok: true }) + await expect(pendingResult).resolves.toBe('rejected:stale connection closed') + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'status.get', + 'worktree.detectedList' + ]) + }) + it('checks advertised runtime capabilities after protocol compatibility', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'status', diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index b9b1cb330d5..4b47315db34 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -13,7 +13,9 @@ const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 type RuntimeCompatibilityCacheEntry = { check: Promise failedAt: number | null - reuseFailure: boolean + // True only once status.get settled and proved compatible. Stays false while + // the probe is in flight, so a recovery clear can drop a doomed pending probe. + provenCompatible: boolean } const runtimeCompatibilityChecks = new Map() @@ -106,7 +108,7 @@ async function ensureRuntimeEnvironmentCompatible( const entry: RuntimeCompatibilityCacheEntry = { check: Promise.resolve(), failedAt: null, - reuseFailure: options.reuseRecentCompatibilityFailure === true + provenCompatible: false } const check = (async () => { const response = await window.api.runtimeEnvironments.call({ @@ -123,6 +125,9 @@ async function ensureRuntimeEnvironmentCompatible( rememberRuntimeEnvironmentCompatibility(environmentId, entry) try { await check + if (runtimeCompatibilityChecks.get(environmentId) === entry) { + entry.provenCompatible = true + } } catch (error) { if (runtimeCompatibilityChecks.get(environmentId) === entry) { // Why: startup asks each remote for repos, groups, then folders; an @@ -148,10 +153,7 @@ function getCachedRuntimeCompatibilityCheck( runtimeCompatibilityChecks.delete(environmentId) return null } - if ( - cached.failedAt !== null && - (!cached.reuseFailure || options.reuseRecentCompatibilityFailure !== true) - ) { + if (cached.failedAt !== null && options.reuseRecentCompatibilityFailure !== true) { return null } runtimeCompatibilityChecks.delete(environmentId) @@ -176,6 +178,22 @@ function rememberRuntimeEnvironmentCompatibility( } } +// Why: a live status.get answer proves any cached compatibility verdict that is +// not a settled success is stale. Drop settled failures AND still-pending probes +// (a probe queued on the dropped connection is doomed, and a reachability- +// triggered refresh must not coalesce onto it) so the refresh re-probes. Only +// proven-compatible successes stay cached. +export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void { + const trimmed = environmentId.trim() + if (!trimmed) { + return + } + const cached = runtimeCompatibilityChecks.get(trimmed) + if (cached && !cached.provenCompatible) { + runtimeCompatibilityChecks.delete(trimmed) + } +} + export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { const trimmed = environmentId?.trim() if (trimmed) { @@ -193,7 +211,7 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { rememberRuntimeEnvironmentCompatibility(trimmed, { check: Promise.resolve(), failedAt: null, - reuseFailure: false + provenCompatible: true }) } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 1300633a949..325acdf879d 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { createCompatibleRuntimeStatusResponse } from '../../runtime/runtime-compatibility-test-fixture' +import { + callRuntimeRpc, + clearRuntimeCompatibilityCacheForTests +} from '../../runtime/runtime-rpc-client' import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' function createSliceStore() { @@ -162,6 +166,125 @@ describe('runtime-status slice', () => { ) }) + it('drops a recent compatibility failure once a status refresh succeeds', async () => { + clearRuntimeCompatibilityCacheForTests() + let offline = true + const call = vi.fn().mockImplementation(({ method }: { method: string }) => { + if (offline || method === 'status.get') { + return Promise.resolve( + offline + ? { + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-a' } + } + : createCompatibleRuntimeStatusResponse('runtime-a') + ) + } + return Promise.resolve({ id: method, ok: true, result: { ok: true }, _meta: {} }) + }) + const getStatus = vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')) + vi.stubGlobal('window', { api: { runtimeEnvironments: { getStatus, call } } }) + const store = createSliceStore() + const target = { kind: 'environment', environmentId: 'env-a' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + // Reuse-flagged callers stay pinned to the recent failure until recovery. + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + + offline = false + await store.getState().refreshRuntimeEnvironmentStatus('env-a') + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).resolves.toEqual({ ok: true }) + clearRuntimeCompatibilityCacheForTests() + }) + + it('drops a recent compatibility failure on a direct non-null status publish', async () => { + // Why: paths like Settings "Connect" publish the host online via + // setRuntimeEnvironmentStatus directly (not refreshRuntimeEnvironmentStatus) + // and then trigger a reuse-flagged repo.list. The stale failure must drop so + // that reuse-flagged catalog fetch re-probes the now-reachable host. + clearRuntimeCompatibilityCacheForTests() + let offline = true + const call = vi.fn().mockImplementation(({ method }: { method: string }) => { + if (offline || method === 'status.get') { + return Promise.resolve( + offline + ? { + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-a' } + } + : createCompatibleRuntimeStatusResponse('runtime-a') + ) + } + return Promise.resolve({ id: method, ok: true, result: { ok: true }, _meta: {} }) + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + const store = createSliceStore() + const target = { kind: 'environment', environmentId: 'env-a' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + + offline = false + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).resolves.toEqual({ ok: true }) + clearRuntimeCompatibilityCacheForTests() + }) + + it('preserves a recent compatibility failure on a null (offline) status publish', async () => { + // Why: recording an unreachable host must not undermine the fanout fix — a + // null status is not proof of reachability, so reuse-flagged sweeps keep + // reusing the one recent failure instead of re-probing per repo. + clearRuntimeCompatibilityCacheForTests() + let offline = true + const call = vi.fn().mockImplementation(({ method }: { method: string }) => { + if (offline || method === 'status.get') { + return Promise.resolve( + offline + ? { + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-a' } + } + : createCompatibleRuntimeStatusResponse('runtime-a') + ) + } + return Promise.resolve({ id: method, ok: true, result: { ok: true }, _meta: {} }) + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + const store = createSliceStore() + const target = { kind: 'environment', environmentId: 'env-a' } as const + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + + // A null publish (host still unreachable) must keep the failure pinned even + // after the transport would answer, so the reuse-flagged caller does not probe. + offline = false + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 }) + + await expect( + callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true }) + ).rejects.toThrow('offline') + clearRuntimeCompatibilityCacheForTests() + }) + it('records null and returns false when a runtime refresh fails', async () => { const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 970f322fe58..b2c5eec0838 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -2,7 +2,10 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { + clearRecentRuntimeCompatibilityFailure, + unwrapRuntimeRpcResult +} from '@/runtime/runtime-rpc-client' /** Live status for one saved runtime environment, as last observed by the * renderer. `status === null` records a probe that failed or timed out so the @@ -65,12 +68,19 @@ export const createRuntimeStatusSlice: StateCreator environment.id)) }, - setRuntimeEnvironmentStatus: (environmentId, status) => + setRuntimeEnvironmentStatus: (environmentId, status) => { + // Why: a non-null status proves the runtime just answered, so drop any stale + // "offline" compat failure before this online transition fires the + // reuse-flagged background refetches — a recovered host must re-probe. + if (status.status !== null) { + clearRecentRuntimeCompatibilityFailure(environmentId) + } set((s) => { const next = new Map(s.runtimeStatusByEnvironmentId) next.set(environmentId, status) return { runtimeStatusByEnvironmentId: next } - }), + }) + }, clearRuntimeEnvironmentStatus: (environmentId) => set((s) => { @@ -103,6 +113,8 @@ export const createRuntimeStatusSlice: StateCreator(response) + // setRuntimeEnvironmentStatus drops any stale compat failure on a non-null + // (reachable) status, so a recovered host's reuse-flagged refetches re-probe. get().setRuntimeEnvironmentStatus(environmentId, { status, checkedAt: Date.now() }) return true } catch { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index b9b5b0bdb27..8929f57594c 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -132,6 +132,7 @@ function createTestStore() { openModal: vi.fn(), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), + ptyIdsByTabId: {}, tabsByWorktree: {}, tabBarOrderByWorktree: {}, pendingReconnectTabByWorktree: {}, @@ -5811,6 +5812,103 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) + it('reuses an offline runtime preflight across hydrated all-worktree refresh repos', async () => { + const store = createTestStore() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ + id: `runtime-repo-${index}`, + path: `/remote/repos/${index}`, + displayName: `runtime-repo-${index}`, + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:env-offline' + })) + + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-offline' } + }) + } + return runtimeEnvironmentCall(args) + }) + + try { + store.setState({ + hasHydratedWorktreePurge: true, + repos + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + } finally { + consoleError.mockRestore() + } + + expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get' + ]) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + }) + + it('does not coalesce a foreground re-probe onto a background reuse:true scan for a remote repo', async () => { + // Why: the reuse flag is part of the coalescing key for remote targets, so a + // foreground fetchWorktrees (reuse:false) must run its own compat-preflighted + // scan instead of sharing a background reuse:true scan that may have reused a + // stale failure — the foreground caller must always re-probe. + const store = createTestStore() + const remote = makeWorktree({ id: 'repo1::/remote/wt', repoId: 'repo1', path: '/remote/wt' }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + hasHydratedWorktreePurge: true, + repos: [ + { + id: 'repo1', + path: '/r1', + displayName: 'R1', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:env-1' + } + ] + } as Partial) + + let scanStartedCount = 0 + const scanReleases: (() => void)[] = [] + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + if (method === 'worktree.detectedList') { + scanStartedCount += 1 + return new Promise((resolve) => { + scanReleases.push(() => + resolve({ + id: 'rpc', + ok: true, + result: makeDetectedResult('repo1', [remote]), + _meta: { runtimeId: 'runtime-remote' } + }) + ) + }) + } + return Promise.resolve({ id: method, ok: true, result: {}, _meta: {} }) + }) + + const background = store.getState().fetchAllWorktrees() + const foreground = store.getState().fetchWorktrees('repo1') + // Flush microtasks so both compat preflights settle and both scans block. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(scanStartedCount).toBe(2) + + scanReleases.forEach((release) => release()) + await Promise.all([background, foreground]) + expect(store.getState().worktreesByRepo.repo1?.map((worktree) => worktree.id)).toEqual([ + remote.id + ]) + }) + it('preserves floating workspace state while purging a real stale worktree', async () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 0ff92d352b7..0eac7020639 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -84,6 +84,10 @@ const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() const detectedWorktreeRefreshesInFlight = new Map>() +type BackgroundRuntimeRefreshOptions = { + reuseRecentCompatibilityFailure?: boolean +} + async function mapReposForWorktreeRefresh( repos: readonly TRepo[], mapper: (repo: TRepo) => Promise @@ -857,7 +861,8 @@ function settingsForWorktreeOwner( async function listDetectedWorktreesForRepo( settings: AppState['settings'], - repoId: string + repoId: string, + options: BackgroundRuntimeRefreshOptions = {} ): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { @@ -875,7 +880,10 @@ async function listDetectedWorktreesForRepo( target, 'worktree.detectedList', { repo: repoId }, - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + } ) } catch (error) { if (!isRuntimeMethodNotFoundError(error)) { @@ -885,7 +893,10 @@ async function listDetectedWorktreesForRepo( target, 'worktree.list', { repo: repoId, limit: REMOTE_WORKTREE_LIST_PARITY_LIMIT }, - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + } ) return toLegacyDetectedWorktreeResult(repoId, legacy) } @@ -894,22 +905,38 @@ async function listDetectedWorktreesForRepo( function detectedWorktreeRefreshKey( settings: AppState['settings'], repoId: string, - options: { executionHostId: ExecutionHostId; requireAuthoritative?: boolean } + options: { + executionHostId: ExecutionHostId + requireAuthoritative?: boolean + reuseRecentCompatibilityFailure?: boolean + } ): string { const target = getActiveRuntimeTarget(settings) const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` - return [ + const parts = [ repoId, options.executionHostId, targetKey, options.requireAuthoritative === true ? 'authoritative' : 'best-effort' - ].join('\n') + ] + // Why: only remote targets run a compat preflight, so a foreground (reuse:false) + // refresh must not coalesce onto a background reuse:true scan that reused a + // stale failure — it must re-probe. Local targets have no preflight; keep them + // coalesced so a foreground/background collision stays one git scan. + if (target.kind === 'environment') { + parts.push(options.reuseRecentCompatibilityFailure === true ? 'reuse-failure' : 'reprobe') + } + return parts.join('\n') } async function listDetectedWorktreesForRepoCoalesced( settings: AppState['settings'], repoId: string, - options: { executionHostId: ExecutionHostId; requireAuthoritative?: boolean } + options: { + executionHostId: ExecutionHostId + requireAuthoritative?: boolean + reuseRecentCompatibilityFailure?: boolean + } ): Promise { const key = detectedWorktreeRefreshKey(settings, repoId, options) const existing = detectedWorktreeRefreshesInFlight.get(key) @@ -918,7 +945,9 @@ async function listDetectedWorktreesForRepoCoalesced( } // Why: startup/event fan-out can ask for the same repo/host refresh many // times at once; share only the scan promise so state merge semantics stay local. - const refresh = listDetectedWorktreesForRepo(settings, repoId) + const refresh = listDetectedWorktreesForRepo(settings, repoId, { + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) detectedWorktreeRefreshesInFlight.set(key, refresh) try { return await refresh @@ -929,7 +958,10 @@ async function listDetectedWorktreesForRepoCoalesced( } } -async function listWorktreeLineageForRuntime(settings: AppState['settings']): Promise<{ +async function listWorktreeLineageForRuntime( + settings: AppState['settings'], + options: BackgroundRuntimeRefreshOptions = {} +): Promise<{ worktreeLineageById: Record workspaceLineageByChildKey: Record }> { @@ -956,7 +988,10 @@ async function listWorktreeLineageForRuntime(settings: AppState['settings']): Pr await callRuntimeRpc<{ lineage: Record workspaceLineage?: Record - }>(target, 'worktree.lineageList', undefined, { timeoutMs: 15_000 }) + }>(target, 'worktree.lineageList', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) ) } @@ -1062,9 +1097,10 @@ function applyWorktreeLineageUpdate( async function refreshWorktreeLineageForSettings( settings: AppState['settings'], - set: Parameters>[0] + set: Parameters>[0], + options: BackgroundRuntimeRefreshOptions = {} ): Promise { - const lineage = await listWorktreeLineageForRuntime(settings) + const lineage = await listWorktreeLineageForRuntime(settings, options) const hostId = getSettingsFocusedExecutionHostId(settings) set((s) => ({ worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), @@ -1084,7 +1120,9 @@ async function refreshRemoteWorktreeLineageBestEffort( return } try { - const lineage = await listWorktreeLineageForRuntime(settings) + const lineage = await listWorktreeLineageForRuntime(settings, { + reuseRecentCompatibilityFailure: true + }) const hostId = getSettingsFocusedExecutionHostId(settings) set((s) => ({ worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), @@ -2361,46 +2399,59 @@ export const createWorktreeSlice: StateCreator // double-probe the IPC for the per-repo success signal. if (get().hasHydratedWorktreePurge) { await mapReposForWorktreeRefresh(repos, async (r) => { - const hostId = getRepoExecutionHostId(r) - const setup = getProjectHostSetupForRepoHost(get(), r.id, hostId) - const settings = settingsForKnownRepoOwner(get().settings, r) - const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { - executionHostId: hostId - }) - const worktrees = sanitizeHostedReviewLinksForBranchClears( - toVisibleWorktrees(detected, hostId, setup), - get().worktreesByRepo[r.id] - ) - set((s) => { - const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, r.id, detected, hostId) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[r.id], - worktrees, - hostId, - matchOptions + try { + const hostId = getRepoExecutionHostId(r) + const setup = getProjectHostSetupForRepoHost(get(), r.id, hostId) + const settings = settingsForKnownRepoOwner(get().settings, r) + const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true + }) + const worktrees = sanitizeHostedReviewLinksForBranchClears( + toVisibleWorktrees(detected, hostId, setup), + get().worktreesByRepo[r.id] ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - matchOptions - ) - if ( - areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && - removedIds.length === 0 - ) { - return s + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + r.id, + detected, + hostId + ) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[r.id], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + setup, + matchOptions + ) + if ( + areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && + removedIds.length === 0 + ) { + return s + } + return { + worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, + sortEpoch: s.sortEpoch + 1, + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + } catch (err) { + if (notifyRuntimeScopeForbiddenIfNeeded(err)) { + return } - return { - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, - sortEpoch: s.sortEpoch + 1, - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) + console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) + } }) return } @@ -2430,7 +2481,7 @@ export const createWorktreeSlice: StateCreator const detected = await listDetectedWorktreesForRepoCoalesced( settingsForKnownRepoOwner(get().settings, r), r.id, - { executionHostId: hostId } + { executionHostId: hostId, reuseRecentCompatibilityFailure: true } ) const current = get().worktreesByRepo[r.id] const list = sanitizeHostedReviewLinksForBranchClears( @@ -2543,7 +2594,9 @@ export const createWorktreeSlice: StateCreator try { // Why: lineage is a focused-host refresh — fetch from the focused host and // host-merge so other hosts' previously fetched lineage is preserved. - await refreshWorktreeLineageForSettings(get().settings, set) + await refreshWorktreeLineageForSettings(get().settings, set, { + reuseRecentCompatibilityFailure: true + }) } catch (err) { console.error('Failed to fetch worktree lineage:', err) } From 1b66de04267bd993416b34969dba8a6266937171 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:14:27 +0000 Subject: [PATCH 038/152] release: v1.4.127-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d34b0b68891..47fa7a69525 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.127-rc.0", + "version": "1.4.127-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From c4db3ca51d4edc4899605d19876da475dfdccae0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:31:34 -0700 Subject: [PATCH 039/152] Fix mobile "phone size" Restore buttons doing nothing (#7749) --- src/main/runtime/mobile-presence-lock.test.ts | 273 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 131 ++++++--- 2 files changed, 367 insertions(+), 37 deletions(-) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 56581919edc..a38084c5199 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -74,13 +74,21 @@ const store = { }) } -function createRuntime() { - const runtime = new OrcaRuntimeService(store) +// Why (#7588): the held-modal repro needs indefinite hold (null) while the +// legacy tests rely on the finite 5s default. Wrap getSettings per-test so a +// caller can pick the hold without mutating the shared stub. +function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { + const effectiveStore = { + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs }) + } + const runtime = new OrcaRuntimeService(effectiveStore) const ptySizes = new Map([ ['pty-1', { cols: 150, rows: 40 }] ]) const resizes: { ptyId: string; cols: number; rows: number }[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] let resizeSucceeds = true runtime.setPtyController({ @@ -107,7 +115,9 @@ function createRuntime() { focusTerminal: vi.fn(), closeTerminal: vi.fn(), sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + fitOverrideEvents.push({ ptyId, mode, cols, rows }) + }, terminalDriverChanged: (ptyId, driver) => { driverEvents.push({ ptyId, driver: { ...driver } }) } @@ -118,6 +128,7 @@ function createRuntime() { ptySizes, resizes, driverEvents, + fitOverrideEvents, setResizeSucceeds: (next: boolean) => { resizeSucceeds = next } @@ -314,11 +325,12 @@ describe('mobile presence lock — multi-mobile semantics', () => { }) it('updateMobileViewport re-fits PTY without flipping the driver', async () => { - const { runtime, ptySizes, driverEvents } = createRuntime() + const { runtime, ptySizes, driverEvents, fitOverrideEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) const before = driverEvents.length + const fitEventsBefore = fitOverrideEvents.length // Keyboard opens — viewport shrinks. await expect( @@ -330,6 +342,10 @@ describe('mobile presence lock — multi-mobile semantics', () => { // Why: a viewport update may re-emit driver to refresh listener // wiring, but it must never go through `idle` (no banner flash). expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) + // Why: phone→phone dim ticks (keyboard show/hide) are the hottest layout + // path and must not wake the renderer's fit-override listeners — the + // emit gate opens only when layout kind or override presence changes. + expect(fitOverrideEvents.length).toBe(fitEventsBefore) }) it('updateMobileViewport late-binds a viewport-less mobile subscriber', async () => { @@ -467,3 +483,252 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) }) + +// Why (#7588): drive the runtime into the reported held-modal state — a phone +// fit that an indefinite hold left behind, followed by a null-viewport +// resubscribe (app update / WebView reload) that re-registers an active +// subscriber with wasResizedToPhone=false while the override is still held. +// This is the state where the desktop "Your phone left this at phone size" +// modal's Restore buttons used to silently no-op. +async function reachHeldModalWithNullViewportResubscribe( + runtime: OrcaRuntimeService +): Promise { + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + // Production RPC passes `params.viewport` straight through, so a client + // that hasn't measured yet arrives here as `undefined`. + await runtime.handleMobileSubscribe('pty-1', 'phone-A', undefined) +} + +describe('mobile presence lock — issue #7588 held-modal restore convergence', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + // Scenario 1: the reported repro end-to-end. Restore must converge and + // notify BOTH the renderer notifier and a runtime listener (paired), since + // remote/web viewers ride the listener channel. + it('reclaim after a null-viewport resubscribe restores dims, clears override, notifies both channels', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + // Held state: driver idle, override still present, phone-sized PTY. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 2: a second click after success is an idempotent no-op. The + // persistent null-viewport subscriber keeps reclaim in the active-subscriber + // branch; assert only "returns true, no new PTY resize, no new fit-override + // event" — a benign mobile-facing mode-change notify is acceptable. + it('second Restore click after success returns true with no new resize or fit-override event', async () => { + const { runtime, resizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + + const resizeCount = resizes.length + const notifierCount = fitOverrideEvents.length + const listenerCount = listenerEvents.length + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(resizes.length).toBe(resizeCount) + expect(fitOverrideEvents.length).toBe(notifierCount) + expect(listenerEvents.length).toBe(listenerCount) + }) + + // Scenario 3: the existing driving take-back is unregressed — a phone that is + // actively driving (wasResizedToPhone=true) still flips to desktop, clears + // the override, and notifies both channels. + it('driving take-back still converges: driver → desktop, override cleared, both channels notified', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1').kind).toBe('mobile') + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 4: a failing resize during a HELD (no-subscriber) restore returns + // false and keeps the override — no lying, no phantom desktop-fit event. + it('held restore with a failing resize returns false and keeps the override', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // Last leaver under indefinite hold → override held, no active subscriber. + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5: a failing resize during an ACTIVE-SUBSCRIBER take-back returns + // false, keeps the override, leaves the driver on its mobile lock, and + // restores the prior display mode ('auto') — the fix #3 P1 correction. + it('active-subscriber take-back with a failing resize returns false and preserves the mobile lock', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + // Driver stays mobile (lock retained) and mode is not left lying at 'desktop'. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5b: after a FAILED active-subscriber take-back, wasResizedToPhone + // must be re-armed so a later unsubscribe under a finite auto-restore setting + // still schedules its timer and eventually clears the override. Without the + // re-arm the flag would be stuck false and the phone-fit would strand. + it('failed take-back re-arms wasResizedToPhone so a later unsubscribe still auto-restores', async () => { + // Finite auto-restore (5s default from the rig store). + const { runtime, ptySizes, setResizeSucceeds } = createRuntime() + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Take-back fails → false, flag re-armed, mode rolled back to 'auto'. + setResizeSucceeds(false) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + // Phone then leaves the terminal. Resize works again for the auto-restore. + setResizeSucceeds(true) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + // Soft-leave grace, then the finite auto-restore timer. + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(5_000) + + // The scheduled auto-restore fired: override cleared, PTY back to desktop. + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale + // held override converges through the shared applyMobileDisplayMode seam. + it('phone-initiated setDisplayMode(desktop) against a stale held override converges', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + + runtime.setMobileDisplayMode('pty-1', 'desktop') + const converged = await runtime.applyMobileDisplayMode('pty-1') + + expect(converged).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 7 (white-box): a held override whose `layouts` entry is gone is + // unreachable via public APIs (onPtyExit deletes both in lockstep), so seed + // it directly. Reclaim must still delete the override and emit a paired + // desktop-fit 0×0 rather than stranding the modal on the next hydrate. + it('orphan cleanup: reclaim on a held override with no layout entry converges', async () => { + const { runtime, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + const internal = runtime as unknown as { + terminalFitOverrides: Map< + string, + { + mode: string + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + > + } + internal.terminalFitOverrides.set('pty-1', { + mode: 'mobile-fit', + cols: 45, + rows: 20, + previousCols: 150, + previousRows: 40, + updatedAt: Date.now(), + clientId: 'phone-A' + }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + ptyId: 'pty-1', + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + expect(listenerEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6463423241e..25038763e17 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7285,13 +7285,30 @@ export class OrcaRuntimeService { // the existing 'resized' event reaches the phone. // 2. Held with no mobile subscriber (post-indefinite-hold): no inner // subscriber to notify; resolve restore target and enqueueLayout - // directly. applyLayout is the SOLE writer of terminalFitOverrides; - // the held branch must not duplicate that mutation. See - // docs/mobile-fit-hold.md. + // directly. applyLayout is the SOLE writer of terminalFitOverrides on + // the normal path, so the held branch defers the mutation to it — the + // one exception is the dead-pty orphan cleanup below, which mirrors + // onPtyExit's sanctioned delete. See docs/mobile-fit-hold.md. + // + // Returns `true` only when the pty ends converged (no fit-override held); + // `false` when a restore was attempted but the resize failed (#7588), or + // when there was nothing to reclaim. On a failed-restore `false`, driver/mode + // are left unchanged so a driving phone keeps its lock and the modal + // truthfully stays — the user can retry. async reclaimTerminalForDesktop(ptyId: string): Promise { if (this.isMobileSubscriberActive(ptyId)) { + // Why (#7588): capture the prior mode so a failed restore can roll the + // routing-only 'desktop' write back — driver/mode transitions must be + // gated on convergence, not committed before we know the resize took. + const priorMode = this.getMobileDisplayMode(ptyId) this.setMobileDisplayMode(ptyId, 'desktop') - await this.applyMobileDisplayMode(ptyId) + const converged = await this.applyMobileDisplayMode(ptyId) + if (!converged) { + // Resize failed — override still held. Leave the driver on its mobile + // lock and undo the mode write so nothing is left lying at 'desktop'. + this.setMobileDisplayMode(ptyId, priorMode) + return false + } this.setDriver(ptyId, { kind: 'desktop' }) // Why: a desktop-initiated reclaim is "I'm taking over right now", // not a sticky preference. The next mobile subscribe (e.g. user @@ -7315,15 +7332,32 @@ export class OrcaRuntimeService { const renderer = this.lastRendererSizes.get(ptyId) const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows - await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - this.setDriver(ptyId, { kind: 'desktop' }) - // Why: a desktop-initiated reclaim is "I'm taking over right now", - // not a sticky preference. Reset to auto so the next mobile subscribe - // re-enters phone-fit. (Held-PTY branch may not have an entry, but - // calling setMobileDisplayMode('auto') is a no-op deletion in that - // case — safe and idempotent.) - this.setMobileDisplayMode(ptyId, 'auto') - return true + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.setDriver(ptyId, { kind: 'desktop' }) + // Why: a desktop-initiated reclaim is "I'm taking over right now", + // not a sticky preference. Reset to auto so the next mobile subscribe + // re-enters phone-fit. (Held-PTY branch may not have an entry, but + // calling setMobileDisplayMode('auto') is a no-op deletion in that + // case — safe and idempotent.) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + // Why (#7588): the layout entry is gone (pty exited) but the override is + // still held — unreachable via public APIs today (onPtyExit deletes both + // in lockstep), but reporting success while stranding the override would + // re-show the modal on the next hydrate. Run the same cleanup onPtyExit + // does (delete override + paired desktop-fit 0×0) so the renderer + // converges; nothing to resize on a dead pty. + if (result.reason === 'pty-exited' && this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + return true + } + // resize-failed: the override remains held; report the truth so the + // modal correctly stays and the user can retry. Driver/mode untouched. + return false } return false } @@ -7439,7 +7473,8 @@ export class OrcaRuntimeService { // // applyLayout is the SOLE writer of: // - this.layouts - // - this.terminalFitOverrides + // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in + // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) // - this.ptyController.resize (i.e. the actual PTY dims) // // Every trigger that wants to change PTY dims or flip mode goes through @@ -7634,11 +7669,17 @@ export class OrcaRuntimeService { this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) } - // Why: emit fit-override-changed only when the *mode* flips. Layouts - // can change dims without flipping mode (keyboard show/hide while - // phone), and waking the renderer on every viewport tick is wasteful - // churn. - if (modeChanged) { + // Why: emit fit-override-changed when the *mode* flips. Layouts can + // change dims without flipping mode (keyboard show/hide while phone), + // and waking the renderer on every viewport tick is wasteful churn. + // Defense-in-depth (#7588): also emit when the override's presence + // changed even without a kind flip. applyLayout is the sole writer and + // keeps override presence in lockstep with layout kind, so overrideChanged + // ≡ modeChanged in every reachable state today; the extra clause fires + // only if that invariant is ever violated, repairing the renderer instead + // of stranding the held modal. + const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') + if (modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". @@ -8036,7 +8077,13 @@ export class OrcaRuntimeService { // Multi-mobile: the most recent mobile actor's viewport drives the active // phone-fit dims. The earliest-by-subscribe-time subscriber's // previousCols/Rows drive the desktop-restore target. - async applyMobileDisplayMode(ptyId: string): Promise { + // + // Returns the post-condition "no fit-override remains held" (#7588): `true` + // when it cleared a held override OR nothing was held to begin with, `false` + // only when a restore was attempted and the resize failed (override rolled + // back, still held). reclaimTerminalForDesktop gates its driver/mode + // transitions on this; other callers ignore it. + async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.getMobileDisplayMode(ptyId) const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null @@ -8045,25 +8092,39 @@ export class OrcaRuntimeService { if (mode === 'desktop') { // Reset wasResizedToPhone on every fitted subscriber so a future // toggle back to auto re-issues the resize. applyLayout owns the - // actual PTY resize + override delete + renderer notify. - let anyWasResized = false - if (inner) { - for (const sub of inner.values()) { - if (sub.wasResizedToPhone) { - anyWasResized = true - sub.wasResizedToPhone = false - } - } + // actual PTY resize + override delete + renderer notify. Track which + // subscribers we cleared so a failed resize can re-arm them. + const clearedFitSubscribers = inner + ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) + : [] + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = false } - if (anyWasResized) { + const anyWasResized = clearedFitSubscribers.length > 0 + // Why (#7588): also restore when a fit-override is still held but no + // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe + // after an indefinite hold resets the flag yet leaves the override, + // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget + // (the same resolver the anyWasResized branch uses) so the two adjacent + // restore paths can never resolve to different dims for the same state. + if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { const restore = this.resolveDesktopRestoreTarget(ptyId) - await this.enqueueLayout(ptyId, { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols: restore.cols, rows: restore.rows }) + // Why (#7588): a failed resize rolls the override back (still held), so + // re-arm the flags we cleared. Otherwise a later unsubscribe under a + // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip + // scheduling its auto-restore timer, and strand the held phone-fit. + if (!result.ok) { + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = true + } + } } else { - // No subscriber was fitted — emit a mode-change resize event so + // Nothing was fitted or held — emit a mode-change resize event so // the mobile client still learns the toggle landed. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { @@ -8082,7 +8143,10 @@ export class OrcaRuntimeService { const viewport = subscriberRecord.viewport if (viewport) { await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) - return + // After a phone-fit an override IS held, so this reports false. The + // auto branch is never reached from reclaim (it sets 'desktop' + // first); computed here only to keep the post-condition uniform. + return !this.terminalFitOverrides.has(ptyId) } } // Why: always emit the mode change even when no resize occurred — the @@ -8098,6 +8162,7 @@ export class OrcaRuntimeService { seq: this.layouts.get(ptyId)?.seq }) } + return !this.terminalFitOverrides.has(ptyId) } // Why: called after a desktop renderer path has successfully resized the From d8df9c818f32d159130f03d1923d99774791292f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:34:29 -0700 Subject: [PATCH 040/152] fix(terminal): allow terminals to spawn outside the worktree (#7685) (#7750) Co-authored-by: Orca --- src/main/ipc/pty.ts | 15 ++--- src/main/pty/terminal-cwd-realpath.test.ts | 43 -------------- src/main/pty/terminal-cwd-realpath.ts | 30 ---------- .../runtime/orca-runtime-terminal-cwd.test.ts | 12 ++-- src/main/runtime/orca-runtime.ts | 19 +++--- src/shared/terminal-startup-cwd.test.ts | 59 +++++++------------ src/shared/terminal-startup-cwd.ts | 52 ++++------------ 7 files changed, 52 insertions(+), 178 deletions(-) delete mode 100644 src/main/pty/terminal-cwd-realpath.test.ts delete mode 100644 src/main/pty/terminal-cwd-realpath.ts diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index d7a5b1986e9..79739fd27ac 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -83,7 +83,6 @@ import { } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd' -import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -2043,19 +2042,15 @@ export function registerPtyHandlers( assertFolderWorkspacePathUsable(status) } - const resolveGuardedPtySpawnCwd = ( + const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, - cwd: string | undefined, - connectionId?: string | null + cwd: string | undefined ): string | undefined => resolveTerminalStartupCwdForWorkspace({ workspaceId: worktreeId, requestedCwd: cwd, resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath, - // Why: realpath only makes sense on the local filesystem; SSH worktree - // paths live on the remote host. - canonicalizePath: localTerminalCwdCanonicalizer(connectionId) + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath }) // Why: the runtime controller must route through getProviderForPty() so that @@ -2068,7 +2063,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) + const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2690,7 +2685,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) + const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) diff --git a/src/main/pty/terminal-cwd-realpath.test.ts b/src/main/pty/terminal-cwd-realpath.test.ts deleted file mode 100644 index 47110648b19..00000000000 --- a/src/main/pty/terminal-cwd-realpath.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, describe, expect, it } from 'vitest' -import { - canonicalizeLocalTerminalPath, - localTerminalCwdCanonicalizer -} from './terminal-cwd-realpath' - -describe('canonicalizeLocalTerminalPath', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-')) - - afterAll(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('resolves symlinks to their target', () => { - const outside = join(root, 'outside') - const worktree = join(root, 'worktree') - mkdirSync(outside) - mkdirSync(worktree) - const link = join(worktree, 'escape') - symlinkSync(outside, link, 'dir') - expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside)) - }) - - it('returns null for nonexistent paths', () => { - expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull() - }) - - it('skips WSL UNC paths instead of touching the 9P share', () => { - expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull() - expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull() - }) -}) - -describe('localTerminalCwdCanonicalizer', () => { - it('is disabled for SSH connections and enabled locally', () => { - expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined() - expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath) - expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath) - }) -}) diff --git a/src/main/pty/terminal-cwd-realpath.ts b/src/main/pty/terminal-cwd-realpath.ts deleted file mode 100644 index f9131d9f01a..00000000000 --- a/src/main/pty/terminal-cwd-realpath.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { realpathSync } from 'node:fs' -import { isWslUncPath } from '../../shared/wsl-paths' - -// Why: terminal startup cwd containment is string-based; realpath closes the -// symlink escape for local paths. Only for local worktrees — SSH/remote paths -// are not resolvable on this filesystem. -export function canonicalizeLocalTerminalPath(targetPath: string): string | null { - // Why: realpath over the WSL 9P share is unreliable and can block the main - // process while the distro boots; keep the string containment check only. - if (isWslUncPath(targetPath)) { - return null - } - try { - return realpathSync.native(targetPath) - } catch { - try { - // Why: realpathSync.native can fail on network/UNC mounts where the - // JS implementation still succeeds (same fallback as git repo scanning). - return realpathSync(targetPath) - } catch { - return null - } - } -} - -export function localTerminalCwdCanonicalizer( - connectionId: string | null | undefined -): ((path: string) => string | null) | undefined { - return connectionId ? undefined : canonicalizeLocalTerminalPath -} diff --git a/src/main/runtime/orca-runtime-terminal-cwd.test.ts b/src/main/runtime/orca-runtime-terminal-cwd.test.ts index 214d2a20894..976b8169fdc 100644 --- a/src/main/runtime/orca-runtime-terminal-cwd.test.ts +++ b/src/main/runtime/orca-runtime-terminal-cwd.test.ts @@ -52,7 +52,7 @@ describe('OrcaRuntimeService terminal startup cwd', () => { ) }) - it('rejects requested terminal cwd values outside the selected worktree', async () => { + it('spawns terminals at a requested cwd outside the selected worktree (#7685)', async () => { const runtime = new OrcaRuntimeService() stubLaunchScope(runtime) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) @@ -63,10 +63,14 @@ describe('OrcaRuntimeService terminal startup cwd', () => { getForegroundProcess: async () => null }) - await expect(runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' })).rejects.toThrow( - 'Terminal cwd must be inside the selected worktree.' + await runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo/app-other', + worktreeId: 'wt-1' + }) ) - expect(spawn).not.toHaveBeenCalled() }) it('reveals main-spawned terminals with their nested startup cwd', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 25038763e17..52d248170ca 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -209,7 +209,6 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' -import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath' import { isWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, @@ -15815,7 +15814,7 @@ export class OrcaRuntimeService { const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) const cwd = - this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) ?? workspace.path + this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path const preAllocatedHandle = this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys @@ -16008,7 +16007,7 @@ export class OrcaRuntimeService { : opts const worktreeId = workspace?.id const cwd = workspace - ? this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) + ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) : launchOpts.cwd const requestId = randomUUID() @@ -16152,7 +16151,7 @@ export class OrcaRuntimeService { this.assertGraphReady() const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const worktreeId = workspace.id - const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { @@ -16389,7 +16388,7 @@ export class OrcaRuntimeService { } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach request. Only // synthesize local serve ids; SSH fresh terminals must call pty.spawn. const stableSessionId = @@ -17313,15 +17312,11 @@ export class OrcaRuntimeService { } } - // Why: every terminal-creation path must apply the same symlink-aware cwd - // guard; keep the canonicalizer wiring in one place. - private resolveGuardedWorkspaceTerminalCwd( - workspace: Pick, + private resolveWorkspaceTerminalStartupCwd( + workspace: Pick, requestedCwd?: string | null ): string | undefined { - return resolveTerminalStartupCwd(workspace.path, requestedCwd, { - canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId) - }) + return resolveTerminalStartupCwd(workspace.path, requestedCwd) } private async resolveTerminalWorkspaceLaunchScope( diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts index f87ef7d51ce..2b1fe0b2f86 100644 --- a/src/shared/terminal-startup-cwd.test.ts +++ b/src/shared/terminal-startup-cwd.test.ts @@ -17,70 +17,53 @@ describe('resolveTerminalStartupCwd', () => { expect(resolveTerminalStartupCwd('/repo/app', 'packages/web')).toBe('/repo/app/packages/web') }) - it('rejects sibling paths outside the worktree', () => { - expect(() => resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toThrow( - 'Terminal cwd must be inside the selected worktree.' - ) + it('allows absolute cwds outside the worktree (#7685)', () => { + // Why: opening/splitting a terminal outside the worktree (e.g. after + // `cd ..`) is allowed; the cwd is resolved, not constrained. + expect(resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toBe('/repo/app-other') }) - it('rejects parent traversal outside the worktree', () => { - expect(() => resolveTerminalStartupCwd('/repo/app', '../other')).toThrow( - 'Terminal cwd must be inside the selected worktree.' - ) + it('resolves parent traversal to a path outside the worktree (#7685)', () => { + expect(resolveTerminalStartupCwd('/repo/app', '../other')).toBe('/repo/other') }) it('trims whitespace-padded requested cwds before resolving', () => { expect(resolveTerminalStartupCwd('/repo/app', ' packages/web ')).toBe('/repo/app/packages/web') }) - it('falls back to the default cwd when a symlink escapes the worktree', () => { - const canonicalize = (path: string): string | null => - path === '/repo/app/link' ? '/outside/target' : path - expect( - resolveTerminalStartupCwd('/repo/app', 'link', { canonicalizePath: canonicalize }) - ).toBeUndefined() + it('returns undefined for an empty requested cwd', () => { + expect(resolveTerminalStartupCwd('/repo/app', '')).toBeUndefined() + expect(resolveTerminalStartupCwd('/repo/app', ' ')).toBeUndefined() + expect(resolveTerminalStartupCwd('/repo/app', null)).toBeUndefined() }) - it('accepts cwds under a symlinked worktree root', () => { - const canonicalize = (path: string): string | null => path.replace(/^\/tmp\//, '/private/tmp/') - expect( - resolveTerminalStartupCwd('/tmp/repo', 'packages/web', { canonicalizePath: canonicalize }) - ).toBe('/tmp/repo/packages/web') - }) - - it('skips the symlink re-check when a path cannot be canonicalized', () => { - expect( - resolveTerminalStartupCwd('/repo/app', 'missing', { canonicalizePath: () => null }) - ).toBe('/repo/app/missing') - }) - - it('handles Windows path containment without case drift', () => { + it('normalizes Windows separators and allows out-of-worktree drives', () => { expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe( 'C:/Repo/App/packages/web' ) - expect(() => resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toThrow( - 'Terminal cwd must be inside the selected worktree.' + expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toBe( + 'C:/Repo/AppOther' ) }) - it('validates renderer PTY cwd values against raw worktree IDs', () => { + it('resolves renderer PTY cwd values against raw worktree IDs', () => { expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: 'repo-1::/repo/app', requestedCwd: '/repo/app/packages/web' }) ).toBe('/repo/app/packages/web') - expect(() => + expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: 'repo-1::/repo/app', requestedCwd: '/repo/app-other' }) - ).toThrow('Terminal cwd must be inside the selected worktree.') + ).toBe('/repo/app-other') }) it('passes floating terminal cwds through untouched', () => { // Why: floating terminal cwds are validated against trusted-directory - // grants in main and have no worktree root to contain within. + // grants in main and have no worktree root to resolve against. expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: FLOATING_TERMINAL_WORKTREE_ID, @@ -89,7 +72,7 @@ describe('resolveTerminalStartupCwd', () => { ).toBe('/Volumes/work/notes') }) - it('refuses the requested cwd when no workspace root is resolvable', () => { + it('falls back to the provider default when no workspace root is resolvable', () => { expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: undefined, @@ -104,7 +87,7 @@ describe('resolveTerminalStartupCwd', () => { ).toBeUndefined() }) - it('validates renderer PTY cwd values against folder workspace keys', () => { + it('resolves renderer PTY cwd values against folder workspace keys', () => { expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: folderWorkspaceKey('folder-1'), @@ -112,12 +95,12 @@ describe('resolveTerminalStartupCwd', () => { resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) }) ).toBe('/repo/app/packages/web') - expect(() => + expect( resolveTerminalStartupCwdForWorkspace({ workspaceId: folderWorkspaceKey('folder-1'), requestedCwd: '../other', resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) }) - ).toThrow('Terminal cwd must be inside the selected worktree.') + ).toBe('/repo/other') }) }) diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index b71b737fa43..ae97ca6dd46 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -1,60 +1,33 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' -import { isPathInsideOrEqual, resolveRuntimePath } from './cross-platform-path' +import { resolveRuntimePath } from './cross-platform-path' import { parseWorkspaceKey } from './workspace-scope' import { splitWorktreeIdForFilesystem } from './worktree-id' -export type TerminalStartupCwdOptions = { - // Why: the string containment check can't see symlinks; only local callers - // can canonicalize — SSH worktree paths live on the remote host. - canonicalizePath?: (path: string) => string | null -} - export function resolveTerminalStartupCwd( worktreePath: string, - requestedCwd?: string | null, - options?: TerminalStartupCwdOptions + requestedCwd?: string | null ): string | undefined { const trimmedCwd = requestedCwd?.trim() if (!trimmedCwd) { return undefined } - const resolvedCwd = resolveRuntimePath(worktreePath, trimmedCwd) - if (!isPathInsideOrEqual(worktreePath, resolvedCwd)) { - // Why: remote/session clients can request terminal cwd; never let that - // become a shell outside the selected workspace. - throw new Error('Terminal cwd must be inside the selected worktree.') - } - const canonicalizePath = options?.canonicalizePath - if (canonicalizePath) { - const canonicalWorktreePath = canonicalizePath(worktreePath) - const canonicalCwd = canonicalizePath(resolvedCwd) - if ( - canonicalWorktreePath && - canonicalCwd && - !isPathInsideOrEqual(canonicalWorktreePath, canonicalCwd) - ) { - // Why: a symlink escaping the worktree can be legitimate (e.g. a pnpm - // store link), so fall back to the default cwd instead of failing the - // spawn — but never grant the requested out-of-worktree shell. - return undefined - } - } - return resolvedCwd + // Why: resolve relative requests against the worktree root and normalize + // `..`; the cwd is intentionally not constrained to the worktree, so opening + // or splitting a terminal outside it (e.g. after `cd ..`) is allowed. (#7685) + return resolveRuntimePath(worktreePath, trimmedCwd) } export function resolveTerminalStartupCwdForWorkspace(args: { workspaceId?: string requestedCwd?: string | null resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined - canonicalizePath?: (path: string) => string | null }): string | undefined { if (!args.requestedCwd || args.requestedCwd.trim().length === 0) { return undefined } if (args.workspaceId === FLOATING_TERMINAL_WORKTREE_ID) { - // Why: floating terminals have no worktree root to contain within; their - // cwd was already validated against the trusted-directory grants in - // resolveFloatingTerminalCwd. + // Why: floating terminals have no worktree root; their cwd was already + // resolved against the trusted-directory grants in resolveFloatingTerminalCwd. return args.requestedCwd } const workspacePath = resolveTerminalWorkspacePath( @@ -62,14 +35,11 @@ export function resolveTerminalStartupCwdForWorkspace(args: { args.resolveFolderWorkspacePath ) if (!workspacePath) { - // Why: without a resolvable workspace root we cannot enforce the - // in-worktree containment check, so refuse the requested cwd rather - // than trusting an unvalidated path. + // Why: without a worktree root we can't anchor a relative request, so fall + // back to the provider default rather than guessing a base. return undefined } - return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, { - canonicalizePath: args.canonicalizePath - }) + return resolveTerminalStartupCwd(workspacePath, args.requestedCwd) } function resolveTerminalWorkspacePath( From 46fe1b57992a04e1329d1a69e6e0545458f71697 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:05:18 -0700 Subject: [PATCH 041/152] Prepare mobile 0.0.25 (#7751) Co-authored-by: Orca --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index b50147f0497..568e7d67656 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.24", + "version": "0.0.25", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From c256a5a4172f82c10331533b6aaeeb4cc883ebc3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:21:07 -0700 Subject: [PATCH 042/152] Windows terminal update-survival (single consolidated PR) (#7538) --- .github/workflows/daemon-relocation-spike.yml | 97 +++ .github/workflows/win-update-e2e.yml | 145 ++++ .github/workflows/win-update-survival-e2e.yml | 116 ++++ config/electron-builder.config.cjs | 6 +- config/nsis/daemon-host-uninstall.nsh | 23 + package.json | 1 + src/main/daemon/daemon-entry.test.ts | 25 + src/main/daemon/daemon-entry.ts | 64 +- src/main/daemon/daemon-file-log.test.ts | 100 +++ src/main/daemon/daemon-file-log.ts | 137 ++++ .../daemon/daemon-host-relocation.test.ts | 270 ++++++++ src/main/daemon/daemon-host-relocation.ts | 398 +++++++++++ src/main/daemon/daemon-init.test.ts | 45 +- src/main/daemon/daemon-init.ts | 78 ++- src/main/daemon/daemon-main.ts | 5 +- src/main/daemon/daemon-server.ts | 25 + src/main/observability/bundle.test.ts | 47 ++ src/main/observability/bundle.ts | 30 +- src/main/observability/index.ts | 37 +- src/main/observability/logs-directory.ts | 41 ++ tools/daemon-relocation-spike/README.md | 160 +++++ .../daemon-relocation-spike/app-inventory.mjs | 146 ++++ tools/daemon-relocation-spike/cli.mjs | 88 +++ .../daemon-relocation-spike/daemon-launch.mjs | 99 +++ tools/daemon-relocation-spike/host-copy.mjs | 62 ++ .../loaded-module-probe.mjs | 85 +++ .../loaded-modules.ps1 | 44 ++ .../daemon-relocation-spike/ndjson-client.mjs | 262 ++++++++ tools/daemon-relocation-spike/selftest.mjs | 166 +++++ tools/daemon-relocation-spike/spike.mjs | 313 +++++++++ .../daemon-relocation-spike/tier-file-set.mjs | 135 ++++ tools/win-update-e2e/README.md | 215 ++++++ tools/win-update-e2e/app-driver.mjs | 357 ++++++++++ tools/win-update-e2e/assertions.mjs | 186 ++++++ tools/win-update-e2e/cli-args.mjs | 201 ++++++ tools/win-update-e2e/daemon-processes.mjs | 173 +++++ tools/win-update-e2e/installer-steps.mjs | 202 ++++++ tools/win-update-e2e/interactivity-probes.mjs | 99 +++ tools/win-update-e2e/onboarding-profile.mjs | 77 +++ tools/win-update-e2e/platform-guard.mjs | 38 ++ tools/win-update-e2e/powershell-runner.mjs | 87 +++ tools/win-update-e2e/preflight.mjs | 161 +++++ .../registry-shortcut-backup.mjs | 312 +++++++++ tools/win-update-e2e/run.mjs | 628 ++++++++++++++++++ tools/win-update-e2e/window-enum.ps1 | 122 ++++ tools/win-update-e2e/window-watch.mjs | 174 +++++ tools/win-update-e2e/window-watch.ps1 | 91 +++ 47 files changed, 6305 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/daemon-relocation-spike.yml create mode 100644 .github/workflows/win-update-e2e.yml create mode 100644 .github/workflows/win-update-survival-e2e.yml create mode 100644 config/nsis/daemon-host-uninstall.nsh create mode 100644 src/main/daemon/daemon-file-log.test.ts create mode 100644 src/main/daemon/daemon-file-log.ts create mode 100644 src/main/daemon/daemon-host-relocation.test.ts create mode 100644 src/main/daemon/daemon-host-relocation.ts create mode 100644 src/main/observability/logs-directory.ts create mode 100644 tools/daemon-relocation-spike/README.md create mode 100644 tools/daemon-relocation-spike/app-inventory.mjs create mode 100644 tools/daemon-relocation-spike/cli.mjs create mode 100644 tools/daemon-relocation-spike/daemon-launch.mjs create mode 100644 tools/daemon-relocation-spike/host-copy.mjs create mode 100644 tools/daemon-relocation-spike/loaded-module-probe.mjs create mode 100644 tools/daemon-relocation-spike/loaded-modules.ps1 create mode 100644 tools/daemon-relocation-spike/ndjson-client.mjs create mode 100644 tools/daemon-relocation-spike/selftest.mjs create mode 100644 tools/daemon-relocation-spike/spike.mjs create mode 100644 tools/daemon-relocation-spike/tier-file-set.mjs create mode 100644 tools/win-update-e2e/README.md create mode 100644 tools/win-update-e2e/app-driver.mjs create mode 100644 tools/win-update-e2e/assertions.mjs create mode 100644 tools/win-update-e2e/cli-args.mjs create mode 100644 tools/win-update-e2e/daemon-processes.mjs create mode 100644 tools/win-update-e2e/installer-steps.mjs create mode 100644 tools/win-update-e2e/interactivity-probes.mjs create mode 100644 tools/win-update-e2e/onboarding-profile.mjs create mode 100644 tools/win-update-e2e/platform-guard.mjs create mode 100644 tools/win-update-e2e/powershell-runner.mjs create mode 100644 tools/win-update-e2e/preflight.mjs create mode 100644 tools/win-update-e2e/registry-shortcut-backup.mjs create mode 100644 tools/win-update-e2e/run.mjs create mode 100644 tools/win-update-e2e/window-enum.ps1 create mode 100644 tools/win-update-e2e/window-watch.mjs create mode 100644 tools/win-update-e2e/window-watch.ps1 diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml new file mode 100644 index 00000000000..319580dc264 --- /dev/null +++ b/.github/workflows/daemon-relocation-spike.yml @@ -0,0 +1,97 @@ +name: Daemon Relocation Spike + +# Why: Phase 1 of the Windows update-survival work relocates the terminal +# daemon's file closure out of the install dir so it survives an update. Before +# implementing it in the app, this spike finds the MINIMAL set of packaged files +# that a copied Orca.exe (run as node) needs to start the daemon and drive a +# ConPTY session from a relocated directory, holding no install-dir file locks. +# Runs from the feature branch via push (workflow_dispatch only works on the +# default branch); main is never touched. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/daemon-relocation-spike/**' + - '.github/workflows/daemon-relocation-spike.yml' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: daemon-relocation-spike-${{ github.ref }} + cancel-in-progress: true + +jobs: + spike: + name: relocation file-set spike + runs-on: windows-2022 + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo to build/run the spike; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the unpacked build keyed on the inputs that affect it, so + # spike-script-only edits skip the ~15 min electron-builder build. + - name: Cache unpacked build + id: cache-unpacked + uses: actions/cache@v4 + with: + path: dist/win-unpacked + key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build unpacked app + if: steps.cache-unpacked.outputs.cache-hit != 'true' + run: pnpm run build:unpack + + - name: Run relocation spike (all tiers) + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $log = "artifacts/spike-output.log" + $tiers = @('full', 'no-gpu', 'minimal') + $any = $false + foreach ($tier in $tiers) { + "===== TIER: $tier =====" | Tee-Object -FilePath $log -Append + $work = Join-Path $env:RUNNER_TEMP "spike-$tier" + # --keep-work-dir so the per-tier daemon stdout/stderr logs survive + # for the artifact upload (the spike otherwise removes work-dir), + # which is what diagnoses a tier that fails to reach ready. + node tools/daemon-relocation-spike/spike.mjs ` + --app-dir dist/win-unpacked ` + --work-dir "$work" ` + --tier $tier ` + --keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append + if ($LASTEXITCODE -eq 0) { $any = $true } + } + if (-not $any) { throw "No tier passed the relocation spike" } + + - name: Upload spike output + if: always() + uses: actions/upload-artifact@v7 + with: + name: daemon-relocation-spike-output + path: | + artifacts/spike-output.log + ${{ runner.temp }}/spike-*/**/*.log + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/win-update-e2e.yml b/.github/workflows/win-update-e2e.yml new file mode 100644 index 00000000000..21c788256b2 --- /dev/null +++ b/.github/workflows/win-update-e2e.yml @@ -0,0 +1,145 @@ +name: Windows Update-Survival E2E + +# Why: proves what happens to terminal sessions across a real Windows app update +# by installing one released version, updating to another, and asserting on the +# packaged artifacts (daemon survival, terminal interactivity, zero console +# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick +# installer uninstalls the registry-registered copy of the app before every +# install, so running the harness on a machine with a real Orca install would +# delete it. A fresh runner has no Orca registered, so it is the only safe home +# for install-based testing. Manual dispatch only. + +# Why: workflow_dispatch only works once a workflow is on the default branch. +# To exercise this from the unmerged feature branch WITHOUT touching main, a +# push trigger scoped to that exact branch runs the workflow from the branch's +# own tree. The paths filter keeps it to harness/workflow edits so ordinary +# commits don't spend 30 min of Windows runner time. Push runs have no inputs, +# so every parameter falls back to a default below. +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/win-update-e2e/**' + - '.github/workflows/win-update-e2e.yml' + workflow_dispatch: + inputs: + from_tag: + description: Release tag to install first (e.g. v1.4.124-rc.8) + required: true + type: string + to_tag: + description: Release tag to update to (e.g. v1.4.124-rc.9) + required: true + type: string + expect: + description: Expected outcome profile + required: true + type: choice + default: cold-restore + options: + - cold-restore + - survival + asset_pattern: + description: Installer asset glob + required: false + type: string + default: '*windows-setup.exe' + soak_seconds: + description: Post-relaunch console-window watch duration + required: false + type: string + default: '60' + +permissions: + contents: read + +# Why: cancel a superseded run when iterating — a new push to the branch makes +# the in-flight Windows job obsolete, so free the runner immediately. +concurrency: + group: win-update-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-e2e: + name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }}) + runs-on: windows-2022 + timeout-minutes: 30 + env: + FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }} + TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }} + EXPECT: ${{ inputs.expect || 'cold-restore' }} + ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }} + SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo and downloads a release; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: the harness only needs its runtime deps (the Playwright Electron + # driver); it drives already-built installer artifacts, so no app build. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: fail fast with a readable message if a tag has no matching Windows + # installer, rather than deep inside the harness. + - name: Download installers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts/from | Out-Null + New-Item -ItemType Directory -Force artifacts/to | Out-Null + gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from + gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to + $from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1 + $to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1 + if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" } + if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" } + "FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Why: no --install-dir here. A CI runner has no real Orca registered, so + # the harness installs to the default per-user location and owns it — the + # isolated-install machinery exists only to (imperfectly) protect a real + # developer install, which does not apply on a throwaway runner. + - name: Run update-survival harness + id: harness + shell: pwsh + env: + # Why: on a driving failure the harness dumps a screenshot + DOM state + # here so the actual post-onboarding UI is visible in the artifacts. + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + $log = "artifacts/harness-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$env:FROM_EXE" ` + --to "$env:TO_EXE" ` + --expect "$env:EXPECT" ` + --soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload harness output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-e2e-output + # Why: log + diagnostics only, never the multi-hundred-MB installers. + path: | + artifacts/harness-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml new file mode 100644 index 00000000000..f5e7cb09309 --- /dev/null +++ b/.github/workflows/win-update-survival-e2e.yml @@ -0,0 +1,116 @@ +name: Windows Update-Survival E2E (branch build) + +# Why: proves the Phase 1 daemon-host relocation actually makes terminal +# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS +# BRANCH, then runs the win-update-e2e harness with --expect survival: install, +# open a terminal, silently update over it, relaunch, and assert the daemon PID +# is unchanged and the pre-update terminal is still interactive with no console +# flashing. Runs from the feature branch via push (workflow_dispatch only works +# on the default branch); main is never touched. A CI runner is the only safe +# place to install/update — see the relocation post-mortem. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'src/main/daemon/**' + - 'src/main/pty/**' + - 'tools/win-update-e2e/**' + - 'config/electron-builder.config.cjs' + - '.github/workflows/win-update-survival-e2e.yml' + workflow_dispatch: + inputs: + expect: + description: Expected outcome profile + required: true + type: choice + default: survival + options: + - survival + - cold-restore + +permissions: + contents: read + +concurrency: + group: win-update-survival-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + survival: + name: survival (branch build over itself) + runs-on: windows-2022 + timeout-minutes: 50 + env: + EXPECT: ${{ inputs.expect || 'survival' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only builds and runs the harness locally; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the built installer keyed on the inputs that affect it, so a + # harness-only edit skips the ~20 min electron-builder build. The daemon + # relocation code lives under src/, so changing it correctly rebuilds. + - name: Cache branch installer + id: cache-installer + uses: actions/cache@v4 + with: + path: dist/orca-windows-setup.exe + key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build Windows installer (unsigned) + if: steps.cache-installer.outputs.cache-hit != 'true' + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=electron + pnpm run build:desktop + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never + + # Why: install the branch build, open a terminal, update the SAME build + # over it, and assert the relocated daemon survives. Same build on both + # sides isolates the survival mechanism (NSIS kill sweep + userData daemon + # + adoption) from cross-version staleness policy. No --install-dir: a CI + # runner has no real Orca to protect. + - name: Run survival harness + id: harness + shell: pwsh + env: + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $exe = "dist/orca-windows-setup.exe" + if (-not (Test-Path $exe)) { throw "Installer not found at $exe" } + $log = "artifacts/survival-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$exe" ` + --to "$exe" ` + --expect "$env:EXPECT" ` + --soak-seconds 60 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload survival output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-survival-output + path: | + artifacts/survival-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a73..51d2a77106a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -173,7 +173,11 @@ module.exports = { artifactName: 'orca-windows-setup.${ext}', shortcutName: '${productName}', uninstallDisplayName: '${productName}', - createDesktopShortcut: 'always' + createDesktopShortcut: 'always', + // Why: on a real uninstall, stop and remove the relocated terminal daemon + // (which lives outside the install dir under LOCALAPPDATA by design). Guarded + // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. + include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') }, mac: { icon: 'resources/build/icon.icns', diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh new file mode 100644 index 00000000000..dc3a497ce67 --- /dev/null +++ b/config/nsis/daemon-host-uninstall.nsh @@ -0,0 +1,23 @@ +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} +!macroend diff --git a/package.json b/package.json index 47fa7a69525..5b853e94bfd 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "win-update-e2e": "node tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index f2ac7a7ade5..e24f1c8b3fe 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -29,4 +29,29 @@ describe('daemon-entry parseArgs', () => { it('throws with no args', () => { expect(() => parseArgs([])).toThrow('Usage:') }) + + it('omits logFilePath when --log-file is absent (adopted old daemons)', () => { + const result = parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token']) + expect(result).not.toHaveProperty('logFilePath') + }) + + it('parses --log-file when present', () => { + const result = parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--log-file', + '/tmp/daemon.log' + ]) + expect(result).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + logFilePath: '/tmp/daemon.log' + }) + }) + + it('still requires --socket and --token when --log-file is given', () => { + expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') + }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index aeffb7aa2a5..713cc840278 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -10,10 +10,20 @@ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' +import { PROTOCOL_VERSION } from './types' -export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { +export type ParsedDaemonArgs = { + socketPath: string + tokenPath: string + /** Optional — absent for adopted old daemons and tests, which log nothing. */ + logFilePath?: string +} + +export function parseArgs(argv: string[]): ParsedDaemonArgs { let socketPath = '' let tokenPath = '' + let logFilePath = '' for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -22,18 +32,24 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri } else if (argv[i] === '--token' && argv[i + 1]) { tokenPath = argv[i + 1] i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFilePath = argv[i + 1] + i++ } } if (!socketPath || !tokenPath) { - throw new Error('Usage: daemon-entry --socket --token ') + throw new Error('Usage: daemon-entry --socket --token [--log-file ]') } - return { socketPath, tokenPath } + return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath } } async function main(): Promise { - const { socketPath, tokenPath } = parseArgs(process.argv.slice(2)) + const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2)) + // Fail-open: a broken log path must never block daemon startup. + const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() + daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) void warmPwshAvailabilityCache() // Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch @@ -55,29 +71,54 @@ async function main(): Promise { msg.includes('EBADF') || msg.includes('ENXIO')) if (isNativeError) { + daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg }) console.error('[daemon] Native PTY exception (suppressed):', err) return } + daemonLog.log('uncaught-exception-fatal', { name: err?.name, message: msg }) console.error('[daemon] Uncaught exception (fatal):', err) throw err }) let daemon: DaemonHandle | null = null + let shuttingDown = false + // Bound the wait so a wedged native shutdown can't leave the daemon running + // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). + const SHUTDOWN_TIMEOUT_MS = 5000 - const shutdown = async (): Promise => { - if (daemon) { - await daemon.shutdown() - daemon = null + const shutdown = async (reason: string): Promise => { + // SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown(). + if (shuttingDown) { + return + } + shuttingDown = true + daemonLog.log('shutdown', { reason }) + try { + if (daemon) { + await Promise.race([ + daemon.shutdown(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('shutdown timeout')), SHUTDOWN_TIMEOUT_MS) + ) + ]) + daemon = null + } + } catch (err) { + // Never let a rejected shutdown() escape as an unhandled rejection and skip exit. + daemonLog.log('shutdown-error', { message: (err as Error)?.message }) + } finally { + daemonLog.close() + process.exit(0) } - process.exit(0) } - process.on('SIGTERM', () => void shutdown()) - process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) daemon = await startDaemon({ socketPath, tokenPath, + log: daemonLog, spawnSubprocess: (opts) => createPtySubprocess(opts) }) @@ -85,6 +126,7 @@ async function main(): Promise { if (process.send) { process.send({ type: 'ready' }) } + daemonLog.log('ready') warmWindowsConptyOnce() } diff --git a/src/main/daemon/daemon-file-log.test.ts b/src/main/daemon/daemon-file-log.test.ts new file mode 100644 index 00000000000..90677b9dab8 --- /dev/null +++ b/src/main/daemon/daemon-file-log.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-file-log-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function readLines(filePath: string): Record[] { + return readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) +} + +describe('createDaemonFileLog', () => { + it('appends NDJSON lines with src/ts/pid/event and terse details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup', { protocolVersion: 18 }) + log.log('session-created', { sessionId: 'abc', pid: 42 }) + + const lines = readLines(filePath) + expect(lines).toHaveLength(2) + expect(lines[0]).toMatchObject({ src: 'daemon', event: 'startup', protocolVersion: 18 }) + expect(typeof lines[0].ts).toBe('string') + expect(lines[0].pid).toBe(process.pid) + expect(lines[1]).toMatchObject({ event: 'session-created', sessionId: 'abc', pid: 42 }) + }) + + it('rotates at the byte cap and keeps only the configured rotated files', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath, { maxBytes: 150, maxRotatedFiles: 2 }) + for (let i = 0; i < 40; i++) { + log.log('tick', { i }) + } + + expect(existsSync(filePath)).toBe(true) + expect(existsSync(`${filePath}.1`)).toBe(true) + expect(existsSync(`${filePath}.2`)).toBe(true) + // Only 2 rotated files are retained — the oldest is dropped, not kept. + expect(existsSync(`${filePath}.3`)).toBe(false) + + // The active file holds the most recent line. + const active = readLines(filePath) + expect(active.at(-1)).toMatchObject({ event: 'tick', i: 39 }) + }) + + it('is fail-open when the log directory cannot be created', () => { + // Make the parent a file so mkdir of the logs subdir fails (ENOTDIR). + const blocker = join(dir, 'blocker') + writeFileSync(blocker, 'x') + const filePath = join(blocker, 'logs', 'daemon.log') + + const log = createDaemonFileLog(filePath) + expect(() => log.log('startup')).not.toThrow() + expect(() => log.close()).not.toThrow() + expect(existsSync(filePath)).toBe(false) + }) + + it('never throws from log() even for non-serializable details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + const circular: Record = {} + circular.self = circular + expect(() => log.log('weird', circular)).not.toThrow() + // The bad line is dropped; a later good line still lands. + log.log('ok') + const lines = readLines(filePath) + expect(lines.map((l) => l.event)).toEqual(['ok']) + }) + + it('close() writes a terminal marker and stops further writes', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup') + log.close() + log.log('after-close') + + const events = readLines(filePath).map((l) => l.event) + expect(events).toEqual(['startup', 'daemon-log-closed']) + }) +}) + +describe('createNoopDaemonFileLog', () => { + it('accepts log/close calls without touching the filesystem', () => { + const log = createNoopDaemonFileLog() + expect(() => { + log.log('startup', { x: 1 }) + log.close() + }).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-file-log.ts b/src/main/daemon/daemon-file-log.ts new file mode 100644 index 00000000000..01090aedf9e --- /dev/null +++ b/src/main/daemon/daemon-file-log.ts @@ -0,0 +1,137 @@ +// Append-only NDJSON logger for the detached daemon process. The daemon runs +// out-of-process with stdio 'ignore', so console output goes nowhere; this +// writes lifecycle events to a rotated file under the app's logs directory so +// they land in diagnostic bundles (windows-terminal-update-survival-plan.md +// §Phase 0). Never log terminal input/output content or tokens. +// +// Two hard constraints: +// 1. FAIL-OPEN. Any error (EACCES, ENOSPC, bad path) disables logging and is +// swallowed — logging must never throw into daemon lifecycle logic or +// affect startup/shutdown. +// 2. Best-effort durability. Each line is a single synchronous appendFileSync +// so a process death mid-write can lose at most the last (partial) line; +// NDJSON readers skip a truncated trailing line. + +import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname } from 'node:path' + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 // 5 MB +const DEFAULT_MAX_ROTATED_FILES = 2 // daemon.log + daemon.log.1 + daemon.log.2 +const PRIVATE_FILE_MODE = 0o600 + +/** Total files in the rotated daemon-log family (active + rotated). The bundle + * collector passes this to `listRotatedFiles` so it reads every rotated file. */ +export const DAEMON_LOG_MAX_FILES = DEFAULT_MAX_ROTATED_FILES + 1 + +export type DaemonFileLog = { + /** Append one lifecycle event. Terse fields only — never user data. */ + log(event: string, details?: Record): void + /** Best-effort marker that no further writes are expected. */ + close(): void +} + +export type DaemonFileLogOptions = { + readonly maxBytes?: number + readonly maxRotatedFiles?: number +} + +/** No-op logger used when the daemon was launched without `--log-file` (adopted + * old daemons, tests). Keeps every call site unconditional. */ +export function createNoopDaemonFileLog(): DaemonFileLog { + return { + log() {}, + close() {} + } +} + +export function createDaemonFileLog( + filePath: string, + opts: DaemonFileLogOptions = {} +): DaemonFileLog { + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + const maxRotatedFiles = opts.maxRotatedFiles ?? DEFAULT_MAX_ROTATED_FILES + + let disabled = false + let currentBytes = 0 + + function disable(): void { + disabled = true + } + + try { + mkdirSync(dirname(filePath), { recursive: true }) + currentBytes = existsSync(filePath) ? statSync(filePath).size : 0 + } catch { + // Unwritable path — stay fail-open; the first log() no-ops via `disabled`. + disable() + } + + // Cascade rename base → .1 → .2, dropping the oldest, then reset the active + // file. Any failure disables logging rather than risking a partial-rotation + // loop that keeps throwing on every subsequent line. + function rotate(): void { + // With no rotated slots there is nothing to cascade; return without the + // `currentBytes = 0` reset below, which would otherwise falsely report the + // still-growing active file as empty and defeat the overflow check forever. + if (maxRotatedFiles < 1) { + return + } + try { + for (let i = maxRotatedFiles; i >= 1; i--) { + const src = i === 1 ? filePath : `${filePath}.${i - 1}` + const dst = `${filePath}.${i}` + if (!existsSync(src)) { + continue + } + if (existsSync(dst)) { + unlinkSync(dst) + } + renameSync(src, dst) + } + currentBytes = 0 + } catch { + disable() + } + } + + function log(event: string, details: Record = {}): void { + if (disabled) { + return + } + let line: string + try { + line = `${JSON.stringify({ + src: 'daemon', + ts: new Date().toISOString(), + pid: process.pid, + event, + ...details + })}\n` + } catch { + // Non-serializable detail (circular ref) — drop the line, never crash. + return + } + const lineBytes = Buffer.byteLength(line, 'utf8') + if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) { + rotate() + if (disabled) { + return + } + } + try { + appendFileSync(filePath, line, { mode: PRIVATE_FILE_MODE }) + currentBytes += lineBytes + } catch { + disable() + } + } + + return { + log, + close(): void { + // Best-effort marker; append is synchronous so there is nothing to flush. + log('daemon-log-closed') + disabled = true + } + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts new file mode 100644 index 00000000000..1d3ab98db90 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -0,0 +1,270 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. +const { electronApp } = vi.hoisted(() => ({ + electronApp: { + isPackaged: true, + userDataPath: '', + version: '9.9.9', + getPath: (): string => electronApp.userDataPath, + getVersion: (): string => electronApp.version + } +})) + +vi.mock('electron', () => ({ app: electronApp })) + +import { + buildDaemonHostManifest, + collectPinnedDaemonVersions, + getRelocatedDaemonHost, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' + +let tempDir: string +let installDir: string +let userDataDir: string +let localAppDataDir: string +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and +// node-pty under resources, mirroring the packaged layout the copy expects. +function buildInstallFixture(root: string): void { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') + for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { + writeFileSync(join(root, name), name) + } + writeFileSync(join(root, 'ffmpeg.dll'), 'dll') + writeFileSync(join(root, 'libEGL.dll'), 'dll') + const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') + mkdirSync(join(mainDir, 'chunks'), { recursive: true }) + writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') + writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') + writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') + const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(nativeDir, { recursive: true }) + writeFileSync(join(nativeDir, 'conpty.node'), 'native') + writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') + mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) + writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') + // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the + // `win32-` prefix); the copy filter keeps the host arch's and drops the other. + const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') + for (const arch of ['win32-x64', 'win32-arm64']) { + mkdirSync(join(prebuildsRoot, arch), { recursive: true }) + writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) + } +} + +// The win32 prebuild dir the running host arch loads vs. the one that is pruned. +const HOST_PREBUILD = `win32-${process.arch}` +const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64' + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-')) + installDir = join(tempDir, 'app') + userDataDir = join(tempDir, 'userData') + mkdirSync(userDataDir, { recursive: true }) + localAppDataDir = join(tempDir, 'localAppData') + mkdirSync(localAppDataDir, { recursive: true }) + process.env.LOCALAPPDATA = localAppDataDir + buildInstallFixture(installDir) + electronApp.isPackaged = true + electronApp.userDataPath = userDataDir + electronApp.version = '9.9.9' + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // Best-effort + } +}) + +describe('buildDaemonHostManifest', () => { + it('mirrors the win-unpacked layout: exe + data blobs + resources tree, no GPU DLLs', () => { + const appDir = 'C:\\app' + const ops = buildDaemonHostManifest({ + appDir, + execPath: 'C:\\app\\Orca.exe', + resourcesPath: 'C:\\app\\resources', + entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + const byDest = new Map(ops.map((op) => [op.destRel, op])) + // The host exe is renamed to a distinct image name (NOT the source basename) + // so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it. + expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file') + expect(byDest.has('Orca.exe')).toBe(false) + const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') + expect(exeOp?.destRel).not.toBe('Orca.exe') + // V8/ICU data blobs are read by the Electron bootstrap and kept. + expect(byDest.has('icudtl.dat')).toBe(true) + // GPU/graphics DLLs are never loaded by the windowless host, so not copied. + expect(byDest.has('ffmpeg.dll')).toBe(false) + expect(byDest.has('libEGL.dll')).toBe(false) + // Daemon bundle + node-pty mirrored at their real resources-relative paths. + expect(byDest.get('resources/app.asar.unpacked/out/main/daemon-entry.js')?.kind).toBe('file') + expect(byDest.get('resources/app.asar.unpacked/out/main/chunks')?.kind).toBe('dir') + // node-pty is copied with a filter dropping .pdb + non-host-arch prebuilds. + const nodePtyOp = byDest.get('resources/node_modules/node-pty') + expect(nodePtyOp?.kind).toBe('dir') + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.node')).toBe(true) + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.pdb')).toBe(false) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${HOST_PREBUILD}/pty.node`)).toBe(true) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${OTHER_PREBUILD}/pty.node`)).toBe(false) + }) +}) + +describe('materializeRelocatedDaemonHost', () => { + it('copies the tree, writes the marker, and returns mirrored fork paths', () => { + const result = materializeRelocatedDaemonHost() + expect(result).not.toBeNull() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(result?.entryPath).toBe( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + ) + expect(existsSync(result!.execPath)).toBe(true) + expect(existsSync(result!.entryPath)).toBe(true) + // node-pty native + conpty runtime copied at the require-resolvable path. + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node') + ) + ).toBe(true) + expect( + existsSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js')) + ).toBe(true) + // Trim: GPU DLLs, .pdb debug symbols, and non-host-arch prebuilds excluded; + // the host arch's prebuild is retained so node-pty resolves its native addon. + expect(existsSync(join(dest, 'ffmpeg.dll'))).toBe(false) + expect(existsSync(join(dest, 'libEGL.dll'))).toBe(false) + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.pdb') + ) + ).toBe(false) + const prebuildsDest = join(dest, 'resources', 'node_modules', 'node-pty', 'prebuilds') + expect(existsSync(join(prebuildsDest, HOST_PREBUILD, 'pty.node'))).toBe(true) + expect(existsSync(join(prebuildsDest, OTHER_PREBUILD, 'pty.node'))).toBe(false) + // Marker records the version + entry rel path, written into the published dir. + const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8')) + expect(marker.version).toBe('9.9.9') + expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') + }) + + it('is idempotent: a valid marker short-circuits without recopying', () => { + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + // A recopy would rm the dest; a sentinel inside it must survive the 2nd call. + const sentinel = join(dest, 'sentinel.txt') + writeFileSync(sentinel, 'keep') + const result = materializeRelocatedDaemonHost() + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(existsSync(sentinel)).toBe(true) + }) + + it('fails open on a missing required input, leaving no dest or staging dir', () => { + rmSync(join(installDir, 'resources', 'node_modules', 'node-pty'), { + recursive: true, + force: true + }) + const result = materializeRelocatedDaemonHost() + expect(result).toBeNull() + const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host') + // Neither the published dest nor any leftover staging dir remains. + const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : [] + expect(remaining).toEqual([]) + }) + + it('returns null off win32', () => { + setProcessProp('platform', 'darwin') + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) +}) + +describe('getRelocatedDaemonHost', () => { + it('returns null when the marker version does not match the current version', () => { + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + mkdirSync(dirname(join(dest, 'x')), { recursive: true }) + writeFileSync(join(dest, 'Orca.exe'), 'exe') + mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true }) + writeFileSync( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), + 'e' + ) + writeFileSync( + join(dest, '.materialized.json'), + JSON.stringify({ + version: '8.8.8', + completedAt: '', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + ) + expect(getRelocatedDaemonHost()).toBeNull() + }) +}) + +describe('pruneOldDaemonHosts', () => { + it('removes unpinned non-current version dirs, keeping current and pinned', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + for (const v of ['9.9.9', '1.0.0', '2.0.0']) { + mkdirSync(join(root, v), { recursive: true }) + } + pruneOldDaemonHosts(new Set(['2.0.0'])) + expect(existsSync(join(root, '9.9.9'))).toBe(true) + expect(existsSync(join(root, '2.0.0'))).toBe(true) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + }) +}) + +describe('collectPinnedDaemonVersions', () => { + it('pins the app version of a live daemon pid file and skips dead ones', () => { + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v4.pid'), + JSON.stringify({ pid: process.pid, startedAtMs: null, appVersion: '7.0.0' }) + ) + writeFileSync( + join(runtimeDir, 'daemon-v3.pid'), + JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' }) + ) + const pinned = collectPinnedDaemonVersions(runtimeDir) + expect(pinned.has('7.0.0')).toBe(true) + expect(pinned.has('6.0.0')).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts new file mode 100644 index 00000000000..ac674fdd290 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.ts @@ -0,0 +1,398 @@ +import { randomBytes } from 'node:crypto' +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { dirname, join, win32 as winPath } from 'node:path' +import { app } from 'electron' +import { parseDaemonPidFile, startTimeMatches } from './daemon-health' + +/** + * Relocates the terminal daemon's process image out of the app install + * directory into userData so it survives Windows auto-updates. + * + * Why: the daemon is forked as plain Node via ELECTRON_RUN_AS_NODE, so its + * image is the install-dir Orca.exe and its loaded modules (node-pty native, + * ConPTY runtime) map from the install dir. On update, electron-builder's NSIS + * installer deletes the old install and force-closes every process whose image + * lives under it — killing the daemon and every live terminal it owns. Copying + * the daemon's whole file closure to a version-keyed userData dir and forking + * from that copy takes its image + loaded modules out of the installer's reach. + * + * The copy keeps the ELECTRON binary run as node (not stock node.exe): a copy + * of Orca.exe (renamed to a distinct image name) is byte-identical, so + * run-as-node behavior — no console flashing, asar-correct — matches the in-dir + * fork exactly. The win-unpacked layout is mirrored verbatim so + * require('node-pty') and node-pty's native loader resolve the relocated tree + * identically to the packaged app. + * + * Fail-open everywhere: any failure returns null and the caller forks the + * install-dir host — the pre-relocation behavior, byte-identical off win32. + */ + +export type RelocatedDaemonHost = { + /** The relocated host exe to fork the daemon from (run as node). */ + execPath: string + /** The copied daemon-entry.js, mirrored under the relocated resources tree. */ + entryPath: string +} + +const HOST_SUBDIR = 'daemon-host' +const MARKER_NAME = '.materialized.json' + +// The relocated host is machine-specific runtime (~260MB). It must live under +// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive +// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder +// name is shared verbatim with the NSIS uninstall cleanup +// (config/nsis/daemon-host-uninstall.nsh), which removes +// %LOCALAPPDATA%\\daemon-host — keep the two in sync. +const LOCAL_HOST_ROOT_NAME = 'Orca' + +// The relocated host exe is a copy of Orca.exe renamed to a distinct image +// name. The NSIS updater's name-based kill (`taskkill /IM Orca.exe`) matches by +// image name, so a distinct name spares the daemon from that branch, while the +// userData path (outside $INSTDIR) spares it from the path-based branch. +const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe' + +// V8 snapshots + ICU data the Electron bootstrap reads even under +// ELECTRON_RUN_AS_NODE; siblings of Orca.exe in win-unpacked. +const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] + +type CopyOp = { + sourcePath: string + /** Destination path relative to the host root, posix-separated. */ + destRel: string + kind: 'file' | 'dir' + /** When true, a missing source is skipped rather than failing the copy. */ + optional?: boolean + /** Per-source-path predicate for dir copies: return false to skip a path. */ + filter?: (sourcePath: string) => boolean +} + +type DaemonHostSources = { + appDir: string + execPath: string + resourcesPath: string + entrySourcePath: string + entryRelPath: string +} + +type MaterializeMarker = { + version: string + completedAt: string + entryRelPath: string +} + +// Uses win32 path semantics so Windows layout paths (drive letters, `\`) +// decompose correctly regardless of host OS — needed for cross-platform unit +// tests; production runs this on win32 only. +function toPosixRelative(fromDir: string, absPath: string): string { + return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') +} + +function destPath(root: string, destRel: string): string { + return join(root, ...destRel.split('/')) +} + +// Mirror getDaemonEntryPath()'s resolution order (unpacked root first, then +// out/main) so the copied entry is the exact file the in-dir fork would run. +function resolveEntrySourcePath(resourcesPath: string): string { + const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') + const direct = join(unpackedRoot, 'daemon-entry.js') + if (existsSync(direct)) { + return direct + } + return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') +} + +// Discover the relocation inputs from the live packaged process, or null when +// relocation does not apply (non-win32, dev, or missing resourcesPath). +function collectDaemonHostSources(): DaemonHostSources | null { + if (process.platform !== 'win32' || !app.isPackaged) { + return null + } + const resourcesPath = process.resourcesPath + if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { + return null + } + const execPath = process.execPath + const appDir = winPath.dirname(execPath) + const entrySourcePath = resolveEntrySourcePath(resourcesPath) + return { + appDir, + execPath, + resourcesPath, + entrySourcePath, + entryRelPath: toPosixRelative(appDir, entrySourcePath) + } +} + +// node-pty ships debug symbols (.pdb) and a win32 prebuild dir per CPU arch; the +// run-as-node daemon loads neither the symbols nor any non-host-arch prebuild +// (verified against the live daemon's loaded module list), so they are filtered +// out of the copy — the bulk of node-pty's on-disk size. Keyed on the host arch +// rather than dropping arm64 outright so a future Windows-arm64 build keeps the +// `win32-arm64` prebuild it actually needs and prunes `win32-x64` instead. +const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() +function isRuntimeNodePtyPath(sourcePath: string): boolean { + const p = sourcePath.toLowerCase() + if (p.endsWith('.pdb')) { + return false + } + // Keep only the host arch's win32 prebuild; drop any other win32- dir. + const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) + return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR +} + +/** + * The ordered copy plan. Every destRel mirrors the source's win-unpacked + * relative path so require() and node-pty's native loader resolve the mirror + * identically to the packaged app. Pure over its inputs so tests can assert the + * layout without a real build. + */ +export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { + const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources + const ops: CopyOp[] = [] + + // Electron host binary + V8/ICU data blobs at the dest root. The exe is + // renamed to a distinct image name so the NSIS updater's name-based + // `taskkill /IM Orca.exe` can't match it; the blobs beside it are read by the + // Electron bootstrap by fixed name. Top-level DLLs are deliberately NOT copied + // — they are all GPU/graphics/media (swiftshader, vulkan, d3d, dxcompiler, + // ffmpeg) that a windowless run-as-node host never loads (verified empirically + // against the live daemon's module list), so copying them only wastes ~48MB. + ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' }) + for (const name of RUNTIME_DATA_FILES) { + ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) + } + + // Daemon bundle: entry + its sibling chunks/ + the unpacked out/package.json + // (CJS/ESM loader resolution), mirrored verbatim. + ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) + const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + + // node-pty package tree (native conpty.node + conpty/ runtime dir). It is a + // sibling of app.asar.unpacked; require('node-pty') resolves it by walking up + // from the mirrored daemon-entry dir to resources/node_modules. Filtered to + // drop .pdb debug symbols and other-arch prebuilds the host never loads. + const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') + ops.push({ + sourcePath: nodePtyDir, + destRel: toPosixRelative(appDir, nodePtyDir), + kind: 'dir', + filter: isRuntimeNodePtyPath + }) + + return ops +} + +function executeManifest(ops: CopyOp[], stagingRoot: string): void { + for (const op of ops) { + if (!existsSync(op.sourcePath)) { + if (op.optional) { + continue + } + throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) + } + const dest = destPath(stagingRoot, op.destRel) + mkdirSync(dirname(dest), { recursive: true }) + const { filter } = op + // Dereference symlinks so the copy holds no link back into the install dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true, + ...(filter ? { filter: (src: string) => filter(src) } : {}) + }) + } +} + +function readMarker(dir: string): MaterializeMarker | null { + try { + const parsed = JSON.parse( + readFileSync(join(dir, MARKER_NAME), 'utf8') + ) as Partial + if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { + return { + version: parsed.version, + completedAt: typeof parsed.completedAt === 'string' ? parsed.completedAt : '', + entryRelPath: parsed.entryRelPath + } + } + } catch { + // Missing/corrupt marker — treat as not materialized. + } + return null +} + +function hostRootDir(): string { + // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME). Fall back to userData only + // if LOCALAPPDATA is somehow unset — a no-op off win32, where relocation never + // runs anyway; on win32 packaged the env var is always present. + const localAppData = process.env.LOCALAPPDATA + const base = + typeof localAppData === 'string' && localAppData.length > 0 + ? join(localAppData, LOCAL_HOST_ROOT_NAME) + : app.getPath('userData') + return join(base, HOST_SUBDIR) +} + +/** + * Cheap idempotency check: the relocated host for the current version, or null. + * Valid only when the marker matches this version AND the exe + entry exist, so + * a partial or stale copy never reports ready. + */ +export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const dest = join(hostRootDir(), version) + const marker = readMarker(dest) + if (!marker || marker.version !== version) { + return null + } + const execPath = join(dest, DAEMON_HOST_EXE_NAME) + const entryPath = destPath(dest, marker.entryRelPath) + if (!existsSync(execPath) || !existsSync(entryPath)) { + return null + } + return { execPath, entryPath } +} + +/** + * Ensure the current version's daemon host is materialized under + * userData/daemon-host/, returning its fork paths or null (fail-open). + * Idempotent: a valid marker for this version short-circuits without recopying. + * The copy stages into a temp sibling and is published by atomic rename, so a + * crash mid-copy never leaves a half-populated dest. + */ +export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { + const existing = getRelocatedDaemonHost() + if (existing) { + return existing + } + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const root = hostRootDir() + const dest = join(root, version) + const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) + try { + mkdirSync(root, { recursive: true }) + rmSync(staging, { recursive: true, force: true }) + executeManifest(buildDaemonHostManifest(sources), staging) + // Marker written LAST: an interrupted copy leaves a marker-less staging dir + // that the next launch discards, never a dest the cheap check trusts. + const marker: MaterializeMarker = { + version, + completedAt: new Date().toISOString(), + entryRelPath: sources.entryRelPath + } + writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) + // Replace any stale/partial dest, then publish the staging dir atomically. + rmSync(dest, { recursive: true, force: true }) + renameSync(staging, dest) + } catch { + try { + rmSync(staging, { recursive: true, force: true }) + } catch { + // Best-effort staging cleanup. + } + return null + } + return getRelocatedDaemonHost() +} + +function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { + try { + process.kill(pid, 0) + } catch { + return false + } + return startTimeMatches(pid, startedAtMs) +} + +/** + * App versions still pinned by a live daemon, read from the daemon-v.pid + * files under `runtimeDir`. A surviving daemon runs from its version's host dir, + * so its dir must never be reclaimed while the process is alive. On win32 the + * start-time check cannot verify, so a matching pid pins conservatively. + */ +export function collectPinnedDaemonVersions(runtimeDir: string): Set { + const pinned = new Set() + let entries + try { + entries = readdirSync(runtimeDir, { withFileTypes: true }) + } catch { + return pinned + } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + } catch { + continue + } + // appVersion null => a pre-relocation daemon forked from the install dir, + // which pins no host dir here. + if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { + pinned.add(parsed.appVersion) + } + } + return pinned +} + +/** + * Reclaim daemon-host/ dirs whose ver is neither the current version nor + * pinned by a live daemon. Best-effort — never throws; a still-locked or + * concurrently-staging dir is simply retried on a future launch. + */ +export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { + if (process.platform !== 'win32' || !app.isPackaged) { + return + } + const version = app.getVersion() + const root = hostRootDir() + let entries + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + continue + } + try { + rmSync(join(root, entry.name), { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } + } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index b6028a7a81c..536d3cc9b24 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -996,7 +996,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1122,7 +1129,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1239,7 +1253,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1499,7 +1520,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1584,7 +1612,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 3ba351c3576..0517f275975 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -36,6 +36,11 @@ import { isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' +import { + collectPinnedDaemonVersions, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getLocalPtyProvider, @@ -44,6 +49,7 @@ import { rebindLocalProviderListeners } from '../ipc/pty' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' +import { getDaemonLogFilePath } from '../observability/logs-directory' import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys @@ -93,6 +99,18 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// Why: the detached daemon writes lifecycle events to a rotated file so field +// failures are diagnosable from a bundle. Honor the same hard privacy switch +// the local trace sink honors (ORCA_DIAGNOSTICS_DISABLED); absence of the arg +// is fully supported, so gating it off is safe and adoption-neutral. +function daemonLogArgs(): string[] { + const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() + if (disabled === '1' || disabled === 'true') { + return [] + } + return ['--log-file', getDaemonLogFilePath()] +} + // Why: before spawning a new daemon, check if an existing one is alive by // attempting a TCP connection to the socket. If it connects, the daemon // survived from a previous app session — reuse it instead of spawning. @@ -264,27 +282,43 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { await killStaleDaemon(runtimeDir, socketPath, tokenPath) const userDataPath = app.getPath('userData') - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Why: detached daemons can outlive dev worktrees. Starting from - // userData keeps process.cwd() valid after a repo/worktree is deleted. - cwd: userDataPath, - // Why: detached + unref lets the daemon outlive the Electron process. - // stdio 'ignore' prevents the child from holding the parent's stdout - // open, which would prevent Electron from exiting cleanly. - detached: true, - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - // Why: the detached daemon is plain Node and cannot call Electron's - // app.getPath(), but shell-ready rcfiles must live outside swept tmp. - ORCA_USER_DATA_PATH: userDataPath + // Why: on win32 packaged, fork from a copy of the Electron runtime staged + // in userData so the daemon's image + loaded modules escape the install dir + // the NSIS updater deletes and force-closes. Staged here (not at app start) + // so the one-time copy stays off the first-paint path and is skipped on + // launches that adopt a live daemon. Fail-open: null → in-dir host, below. + const relocatedHost = materializeRelocatedDaemonHost() + // Fork the relocated entry when available; otherwise the install-dir entry. + const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + const child = fork( + forkEntryPath, + ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()], + { + // Why: detached daemons can outlive dev worktrees. Starting from + // userData keeps process.cwd() valid after a repo/worktree is deleted. + cwd: userDataPath, + // Why: detached + unref lets the daemon outlive the Electron process. + // stdio 'ignore' prevents the child from holding the parent's stdout + // open, which would prevent Electron from exiting cleanly. + detached: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + // Why: run the relocated Orca.exe copy instead of the install-dir one. + // It is byte-identical, so run-as-node behavior is unchanged; only the + // image path moves out of the updater's kill zone. + ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), + // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain + // Node.js process instead of an Electron renderer/main process. Without + // it, Electron's GPU/display initialization can interfere with native + // module operations like node-pty's posix_spawn of the spawn-helper. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Why: the detached daemon is plain Node and cannot call Electron's + // app.getPath(), but shell-ready rcfiles must live outside swept tmp. + ORCA_USER_DATA_PATH: userDataPath + } } - }) + ) // Wait for the daemon to signal readiness via IPC await new Promise((resolve, reject) => { @@ -398,6 +432,10 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise // throws, a stale spawner would prevent shutdownDaemon() from cleaning up // correctly on retry. const info = await newSpawner.ensureRunning() + // Reclaim superseded daemon-host copies on EVERY launch, not just on a fresh + // spawn: surviving daemons make spawns rare, so a spawn-only sweep would let + // old-version copies accumulate. Current + live-daemon-pinned versions stay. + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) const launchMode = newSpawner.getHandle()?.mode logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63078067c93..82f4dad7b7e 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,9 +1,11 @@ import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { socketPath: string tokenPath: string spawnSubprocess: DaemonServerOptions['spawnSubprocess'] + log?: DaemonFileLog } export type DaemonHandle = { @@ -14,7 +16,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise + log?: DaemonFileLog spawnSubprocess: (opts: { sessionId: string cols: number @@ -48,6 +50,7 @@ export class DaemonServer { private socketPath: string private tokenPath: string private ptySpawnHealthCheck: () => Promise + private log: DaemonFileLog private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -66,6 +69,7 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.log = opts.log ?? createNoopDaemonFileLog() } async start(): Promise { @@ -139,23 +143,30 @@ export class DaemonServer { ): void { const hello = msg as HelloMessage if (hello.type !== 'hello') { + this.log.log('client-hello-rejected', { reason: 'expected-hello' }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) socket.destroy() return } if (hello.version !== PROTOCOL_VERSION) { + this.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) socket.destroy() return } if (hello.token !== this.token) { + this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) socket.destroy() return } + this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) socket.write(encodeNdjson({ type: 'hello', ok: true })) if (hello.role === 'control') { @@ -297,6 +308,7 @@ export class DaemonServer { onExit: (code) => { // Why: exit tears down renderer handlers; flush final output first // so the last few milliseconds of PTY data are not stranded. + this.log.log('session-exited', { sessionId: p.sessionId, code }) this.streamDataBatcher.flush(clientId) this.lastInputAtBySessionId.delete(p.sessionId) if (client?.streamSocket) { @@ -312,6 +324,10 @@ export class DaemonServer { } } }) + this.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: p.sessionId, + pid: result.pid + }) return { isNew: result.isNew, snapshot: result.snapshot, @@ -349,6 +365,10 @@ export class DaemonServer { case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) + this.log.log('session-killed', { + sessionId: request.payload.sessionId, + immediate: request.payload.immediate === true + }) this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) return {} @@ -359,6 +379,7 @@ export class DaemonServer { case 'detach': // Note: detach token handling is simplified here — full implementation // would track tokens per client + this.log.log('session-detached', { sessionId: request.payload.sessionId }) return {} case 'getCwd': @@ -402,6 +423,10 @@ export class DaemonServer { return { healthy: true } case 'shutdown': + this.log.log('shutdown', { + reason: 'rpc', + killSessions: request.payload.killSessions === true + }) if (request.payload.killSessions) { this.host.dispose() } diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index d532711828a..347e1025893 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -140,6 +140,53 @@ describe('bundle — collection', () => { expect(bundle.payload).not.toContain('"name":"old"') }) + it('merges the daemon lifecycle log, bounded by the same lookback', () => { + const daemonFile = join(dir, 'daemon.log') + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + writeFileSync( + daemonFile, + makeNDJSON([ + { src: 'daemon', ts: new Date().toISOString(), pid: 1, event: 'startup' }, + { + src: 'daemon', + // 1h ago — outside the 30m window, must be dropped. + ts: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + pid: 1, + event: 'session-exited' + } + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + lookbackMinutes: 30, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"event":"startup"') + expect(bundle.payload).toContain('"name":"recent"') + expect(bundle.payload).not.toContain('"event":"session-exited"') + }) + + it('collects no daemon log lines when no daemon log path is given', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).not.toContain('"src":"daemon"') + }) + it('runs the redactor on the merged payload (belt-and-suspenders)', () => { // Simulate a sink-write bug that leaked a secret through. The bundle // pass should still strip it. diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 812e354d51f..34011d9e4aa 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -39,6 +39,10 @@ const DEFAULT_LOOKBACK_MINUTES = 30 export type CollectBundleOptions = { readonly traceFilePath: string readonly maxFiles: number + /** Detached-daemon lifecycle log. Its rotated family is merged into the + * bundle so daemon-side failures are diagnosable from a field report. */ + readonly daemonLogFilePath?: string + readonly daemonLogMaxFiles?: number readonly lookbackMinutes?: number readonly appVersion: string readonly platform: string @@ -95,7 +99,8 @@ function* readLinesNewestFirst(text: string): Iterable { */ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 - const cutoffNanos = BigInt(Date.now() - lookbackMs) * 1_000_000n + const cutoffMs = Date.now() - lookbackMs + const cutoffNanos = BigInt(cutoffMs) * 1_000_000n const bundleSubmissionId = generateBundleSubmissionId() const header: BundleHeader = { bundle_submission_id: bundleSubmissionId, @@ -123,7 +128,15 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // older than the cutoff in an older file we can stop entirely. We don't // optimize that yet; the worst case (10 × 10 MB = 100 MB scan) takes // <1 s on a modern SSD and bundles are user-initiated, not hot-path. - const files = listRotatedFiles(opts.traceFilePath, opts.maxFiles) + // Trace spans first (the primary payload), then the daemon lifecycle log. + // Daemon records carry an ISO `ts` instead of `endTimeUnixNano`; both are + // filtered by the same lookback below. + const files = [ + ...listRotatedFiles(opts.traceFilePath, opts.maxFiles), + ...(opts.daemonLogFilePath + ? listRotatedFiles(opts.daemonLogFilePath, opts.daemonLogMaxFiles ?? opts.maxFiles) + : []) + ] outer: for (const file of files) { let text: string try { @@ -153,7 +166,11 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { continue } - const record = parsed as { startTimeUnixNano?: string; endTimeUnixNano?: string } + const record = parsed as { + startTimeUnixNano?: string + endTimeUnixNano?: string + ts?: string + } // Filter by end-time, not start-time. A long-lived span started 35 // minutes ago but ending inside the lookback is exactly what we want // in the bundle for diagnosing "session crashed at minute 32." @@ -166,6 +183,13 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // Non-numeric end-time — keep it; better to over-include than to // drop a record we couldn't classify. } + } else if (typeof record.ts === 'string') { + // Daemon lifecycle lines timestamp with an ISO `ts`; bound them by the + // same lookback window. Unparseable timestamps are kept (over-include). + const tsMs = Date.parse(record.ts) + if (Number.isFinite(tsMs) && tsMs < cutoffMs) { + continue + } } // Run the redactor a SECOND TIME over the parsed shape, in server mode. diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 9ab216d50e1..791653d272d 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -28,15 +28,14 @@ // import isolation rule above. The cost of one duplicated array vs. // punching a hole in the architecture is trivially worth it. -import { app } from 'electron' -import { homedir, platform } from 'node:os' -import { join } from 'node:path' import { createLocalFileSink, DEFAULT_MAX_FILES, getRotatedFamilySize, type LocalFileSink } from './local-file-sink' +import { getDaemonLogFilePath, getTraceFilePath } from './logs-directory' +import { DAEMON_LOG_MAX_FILES } from '../daemon/daemon-file-log' import { collectBundle as _collectBundle, type CollectBundleOptions, @@ -128,30 +127,9 @@ export function resolveObservabilityConsent(): ObservabilityConsent { } } -/** Path for the trace NDJSON file. macOS conventional location is - * `~/Library/Application Support/Orca/logs/main.trace.ndjson`; we resolve - * the same intent on Windows / Linux via Electron's `userData` dir. The - * function falls back to homedir when Electron is not available (tests). - */ -export function getTraceFilePath(): string { - let userData: string - try { - userData = app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use a sensible - // OS-conventional fallback so unit tests can construct the path - // without spinning up the full Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - userData = join(home, 'Library', 'Application Support', 'Orca') - } else if (platform() === 'win32') { - userData = join(process.env.APPDATA ?? home, 'Orca') - } else { - userData = join(home, '.config', 'Orca') - } - } - return join(userData, 'logs', 'main.trace.ndjson') -} +// Re-exported so existing importers of the trace path keep working; the +// resolution now lives in one place alongside the daemon log path. +export { getTraceFilePath } from './logs-directory' // ── Module-level state ─────────────────────────────────────────────────── @@ -237,6 +215,11 @@ export function collectDiagnosticBundle( return _collectBundle({ traceFilePath: getTraceFilePath(), maxFiles: DEFAULT_MAX_FILES, + // Why: the detached daemon writes its lifecycle log to a separate file, so + // the bundle collector must be pointed at it explicitly — it does not glob + // the logs directory. + daemonLogFilePath: getDaemonLogFilePath(), + daemonLogMaxFiles: DAEMON_LOG_MAX_FILES, ...meta }) } diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts new file mode 100644 index 00000000000..94a1b7baae8 --- /dev/null +++ b/src/main/observability/logs-directory.ts @@ -0,0 +1,41 @@ +// Single source of truth for the app's logs directory and the files inside it. +// macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and +// Linux resolve the same intent via Electron's `userData` dir. Falls back to a +// homedir-derived path when Electron's `app` is unavailable (unit tests). + +import { app } from 'electron' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' + +function getUserDataDir(): string { + try { + return app.getPath('userData') + } catch { + // Tests — Electron's `app` may not be initialized. Use an OS-conventional + // fallback so callers can resolve the path without the Electron runtime. + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') + } +} + +export function getLogsDirectory(): string { + return join(getUserDataDir(), 'logs') +} + +/** NDJSON trace file written by the main-process error-tracking sink. */ +export function getTraceFilePath(): string { + return join(getLogsDirectory(), 'main.trace.ndjson') +} + +/** NDJSON lifecycle log written by the detached daemon process. Shared here so + * the daemon fork (which passes it as `--log-file`) and the bundle collector + * (which reads it) agree on one path. */ +export function getDaemonLogFilePath(): string { + return join(getLogsDirectory(), 'daemon.log') +} diff --git a/tools/daemon-relocation-spike/README.md b/tools/daemon-relocation-spike/README.md new file mode 100644 index 00000000000..d7edd313a63 --- /dev/null +++ b/tools/daemon-relocation-spike/README.md @@ -0,0 +1,160 @@ +# daemon-relocation-spike + +A throwaway probe that answers one empirical question for Phase 1 of the +Windows update-survival work: + +> What is the **minimal set of files** that must be copied out of a packaged +> `win-unpacked` build so that a **copied `Orca.exe`** — run with +> `ELECTRON_RUN_AS_NODE=1` from a directory **outside** the install dir — can: +> (a) start the terminal daemon and signal ready over IPC, +> (b) spawn a real ConPTY `node-pty` session, write input, and read output back, +> (c) do all of that while holding **no open file handles into the app/install +> dir**, so an NSIS update could delete the original install. + +## Why this matters + +The daemon is `fork()`ed from the app's own Electron binary (`Orca.exe`) with +`ELECTRON_RUN_AS_NODE=1`, from the **install** directory. On a Windows update, +electron-builder's NSIS installer (a) runs `uninstallOldVersion` (deletes the +registered install's files) and (b) `CHECK_APP_RUNNING` force-closes every +process whose image path is under `$INSTDIR`. So the daemon dies and its held +file locks can break the update. + +The Phase 1 fix copies the daemon's whole file closure **out** of the install +dir (into `%LOCALAPPDATA%`/userData) and forks the daemon from the **copy**, so +its image + all loaded modules live outside `$INSTDIR`. This spike measures how +small that copy can be while still working. + +We keep the **Electron binary run as node** (not a stock `node.exe`): a prior +attempt (#7473, reverted) switched to stock node and caused Windows-console +flashing (stock node lacks Electron's `kHideConsoleWindows`) plus asar breakage. +This spike does **not** reintroduce stock node. + +## Usage + +``` +# Real run (Windows, needs a packaged win-unpacked build): +node tools/daemon-relocation-spike/spike.mjs \ + --app-dir \ + --work-dir \ + [--tier full|no-gpu|minimal] \ + [--keep-work-dir] + +# Offline logic validation (any OS, no build, no launch): +node tools/daemon-relocation-spike/spike.mjs --selftest +``` + +Exit code is `0` only when the run **PASSES**: daemon ready, PTY echo +round-trips the nonce, the daemon's main module is the copied `Orca.exe`, and +**no** loaded module resolves under `--app-dir`. + +## Tiers (defined as data in `tier-file-set.mjs`) + +Every tier includes the irreducible core: `Orca.exe`, `icudtl.dat`, both V8 +snapshot blobs (`snapshot_blob.bin`, `v8_context_snapshot.bin`), the daemon +bundle (`out/main/daemon-entry.js` + `chunks/` + `out/package.json`), and the +whole `node-pty` package (native `conpty.node` + the sibling `conpty/` runtime +dir holding `conpty.dll` + `OpenConsole.exe`). + +Tiers differ only in which top-level `*.dll` files they carry: + +| Tier | Top-level DLLs | +| --------- | ---------------------------------------------------------------- | +| `full` | **all** top-level `*.dll` | +| `no-gpu` | all **except** GPU/render DLLs (`libEGL`, `libGLESv2`, `vk_swiftshader`, `vulkan-1`, `d3dcompiler_47`); **keeps** `ffmpeg.dll` | +| `minimal` | **none** (exe + data blobs + daemon bundle + node-pty only) | + +Trimming further is a config change (edit `TIER_DEFINITIONS` / `GPU_DLLS`), not +a code change. + +## How node-pty's native + ConPTY runtime is handled + +The whole `node-pty` package tree is copied, and **the entire win-unpacked +layout is mirrored verbatim** (every copy destination is relative to the +win-unpacked root, not the asar-unpacked root). This matters because node-pty is +packaged at `resources/node_modules/node-pty` — a **sibling** of +`app.asar.unpacked`, not under it (see +`config/packaged-runtime-node-modules.cjs`). Mirroring the full layout preserves +two resolutions from the relocated path: + +1. **The daemon require-closure** resolves `require('node-pty')` by walking + parent dirs up from the mirrored + `resources/app.asar.unpacked/out/main/daemon-entry.js`, which passes through + `resources/` and finds `resources/node_modules/node-pty` — exactly as in the + packaged app. +2. **node-pty's own native loader** resolves `conpty.node` from `build/Release` + (or `prebuilds/win32-`) relative to node-pty's own `__dirname`, and + node-pty's Windows addon loads `conpty.dll` from `/conpty/` + and spawns `OpenConsole.exe` from beside it. Copying the tree verbatim keeps + all three side-by-side. + +### `ORCA_NODE_PTY_NATIVE_DIR` + +The reverted #7421 added a `node-pty` patch that reads +`ORCA_NODE_PTY_NATIVE_DIR` to override the native dir. **The current branch's +`config/patches/node-pty@1.1.0.patch` does NOT contain that override** — it was +reverted. The spike therefore relies on **layout preservation** (copying the +node-pty tree at its default relative path) rather than the env override. The +spike still *sets* `ORCA_NODE_PTY_NATIVE_DIR` to the relocated native dir so it +keeps working if pointed at a build that carries the patch, but on this branch +the var is inert. + +**Implication for the real Phase 1 implementation:** if the production copy does +NOT preserve node-pty at the path its loader resolves by default (e.g. if the +daemon-entry is relocated without the sibling `node_modules/node-pty`), the impl +will need to **re-add the `ORCA_NODE_PTY_NATIVE_DIR` patch** from #7421. If it +mirrors the layout as this spike does, the patch is not strictly required — +though re-adding it is the more robust choice. + +## The handshake / client + +`ndjson-client.mjs` is a small standalone NDJSON client (no electron/src +imports) that mirrors `src/main/daemon/daemon-server.ts`: + +1. Read the token the server writes to the token file after it begins listening. +2. Open a **control** socket, send `hello {role:'control'}`, await + `{type:'hello', ok:true}`. +3. Open a **stream** socket with the **same** `clientId`, send + `hello {role:'stream'}`. +4. `createOrAttach` on control, then `write` `echo SPIKE-OK-\r\n`, and + read `data` events on the stream socket until the nonce appears **alone at + line start** (executed output, distinct from the echoed input line). + +`PROTOCOL_VERSION` is read at runtime from `src/main/daemon/types.ts` so the +client never drifts from the daemon. + +## The handle probe + +`loaded-modules.ps1` (via `loaded-module-probe.mjs`) runs +`Get-Process -Id ` and enumerates `.Modules[].FileName`. Any module path +under `--app-dir` is a **lock risk** (the installer cannot replace a file a live +process maps), so a passing relocation must show **zero**. It also asserts the +process's **main module** is the copied `Orca.exe`, not the install-dir one. + +Loaded DLLs are the lock-critical set. Data files (`icudtl.dat`, asar) are not +memory-mapped as modules, so this probe does not enumerate them — the copy plan +handles those by construction (they are copied, so nothing opens the originals). + +## What remains unverified until CI runs it + +This session has **no build**, so the launch path is unproven. Verified here: +`node --check` on every `.mjs`, a green `--selftest`, and clean +`pnpm exec oxlint`. Open questions the real CI run must answer: + +- Whether `TIER_MINIMAL` (no top-level DLLs) boots `Orca.exe` as node at all, or + whether run-as-node still needs `ffmpeg.dll` / others — this is the core + empirical result. +- Whether the daemon bundle require-closure needs any **other** unpacked + `node_modules` beyond `node-pty` (surfaces as a ready-timeout if so). +- Whether any loaded module still resolves under `--app-dir` (the handle probe + will name it). + +## Recommendation for the likely-minimal tier + +`no-gpu` is the safe minimal target to ship: run-as-node Electron does not +initialize the GPU/render stack, so `libEGL` / `libGLESv2` / `vk_swiftshader` / +`vulkan-1` / `d3dcompiler_47` are very unlikely to load, while `ffmpeg.dll` and +the ICU/snapshot data are retained because the Electron bootstrap references +them regardless of run-as-node. Run `--tier minimal` on CI first: if it PASSES, +ship minimal; if `Orca.exe` fails to boot without the non-GPU DLLs, fall back to +`no-gpu`. `full` is the always-works upper bound for comparison. diff --git a/tools/daemon-relocation-spike/app-inventory.mjs b/tools/daemon-relocation-spike/app-inventory.mjs new file mode 100644 index 00000000000..0460d1d4cb7 --- /dev/null +++ b/tools/daemon-relocation-spike/app-inventory.mjs @@ -0,0 +1,146 @@ +// Locates the daemon-host inputs inside a packaged win-unpacked build. +// +// Layout the packager produces (see config/electron-builder.config.cjs and +// config/packaged-runtime-node-modules.cjs): +// /Orca.exe electron binary (run as node) +// /icudtl.dat ICU data (needed even as node) +// /snapshot_blob.bin V8 snapshot +// /v8_context_snapshot.bin V8 context snapshot +// /*.dll electron/GPU runtime DLLs +// /resources/app.asar.unpacked/out/main/daemon-entry.js (+ chunks/) +// /resources/app.asar.unpacked/node_modules/node-pty/** native + conpty + +import { existsSync, readdirSync, statSync } from 'node:fs' +import { basename, join } from 'node:path' + +export const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] +export const HOST_EXE = 'Orca.exe' + +// Windows arch dir names node-pty prebuilds ship under; build/Release is the +// packaged rebuild location and takes precedence. +const NODE_PTY_NATIVE_CANDIDATES = ['build/Release', 'prebuilds/win32-x64', 'prebuilds/win32-arm64'] + +function fileEntry(dir, name) { + const path = join(dir, name) + if (!existsSync(path)) { + return { name, path, exists: false, size: 0 } + } + return { name, path, exists: true, size: statSync(path).size } +} + +function listTopLevelDlls(appDir) { + const dlls = [] + for (const name of readdirSync(appDir)) { + if (name.toLowerCase().endsWith('.dll')) { + dlls.push(fileEntry(appDir, name)) + } + } + return dlls.sort((a, b) => a.name.localeCompare(b.name)) +} + +// Resolve the app.asar.unpacked root: everything the forked daemon-entry +// require-closure resolves (chunks, node-pty) lives under it, so the copied +// host must mirror it verbatim. +function resolveUnpackedRoot(appDir) { + const candidate = join(appDir, 'resources', 'app.asar.unpacked') + return existsSync(candidate) ? candidate : null +} + +// getDaemonEntryPath() in daemon-init.ts probes daemon-entry.js at the unpacked +// root first, then out/main — mirror that resolution order here. +function resolveDaemonEntry(unpackedRoot) { + if (!unpackedRoot) { + return { name: 'daemon-entry.js', path: '', exists: false, size: 0, relFromUnpacked: '' } + } + const direct = join(unpackedRoot, 'daemon-entry.js') + if (existsSync(direct)) { + return { ...fileEntry(unpackedRoot, 'daemon-entry.js'), relFromUnpacked: 'daemon-entry.js' } + } + const nested = join('out', 'main', 'daemon-entry.js') + const nestedPath = join(unpackedRoot, nested) + return { + name: 'daemon-entry.js', + path: nestedPath, + exists: existsSync(nestedPath), + size: existsSync(nestedPath) ? statSync(nestedPath).size : 0, + relFromUnpacked: nested.split('\\').join('/') + } +} + +function resolveNodePty(unpackedRoot, appDir) { + // Prefer the unpacked-root copy (what the daemon require-closure resolves); + // fall back to a resources-level copy some builds also stage. + const roots = [] + if (unpackedRoot) { + roots.push(join(unpackedRoot, 'node_modules', 'node-pty')) + } + roots.push(join(appDir, 'resources', 'node_modules', 'node-pty')) + + for (const dir of roots) { + if (!existsSync(dir)) { + continue + } + for (const rel of NODE_PTY_NATIVE_CANDIDATES) { + const nativeDir = join(dir, ...rel.split('/')) + if (existsSync(join(nativeDir, 'conpty.node'))) { + return { + exists: true, + packageDir: dir, + nativeDir, + nativeRel: rel, + conptyNode: fileEntry(nativeDir, 'conpty.node'), + // node-pty's Windows addon loads conpty.dll from /conpty/. + conptyDll: fileEntry(join(nativeDir, 'conpty'), 'conpty.dll'), + openConsole: fileEntry(join(nativeDir, 'conpty'), 'OpenConsole.exe') + } + } + } + // node-pty present but no ConPTY native found under known dirs. + return { exists: true, packageDir: dir, nativeDir: '', nativeRel: '', conptyNode: null } + } + return { exists: false, packageDir: '', nativeDir: '', nativeRel: '', conptyNode: null } +} + +/** + * Discover every daemon-host input in `appDir`. Never throws for missing files; + * each entry carries an `exists` flag so the caller can print a full report and + * decide whether the chosen tier is buildable. + */ +export function inventoryAppDir(appDir) { + const unpackedRoot = resolveUnpackedRoot(appDir) + return { + appDir, + unpackedRoot, + hostExe: fileEntry(appDir, HOST_EXE), + runtimeData: RUNTIME_DATA_FILES.map((name) => fileEntry(appDir, name)), + topLevelDlls: listTopLevelDlls(appDir), + daemonEntry: resolveDaemonEntry(unpackedRoot), + nodePty: resolveNodePty(unpackedRoot, appDir) + } +} + +/** Human-readable inventory dump for the run report. */ +export function formatInventory(inv) { + const lines = [] + const kib = (n) => `${(n / 1024).toFixed(1)} KiB` + const mark = (e) => (e.exists ? 'OK ' : 'MISS') + lines.push(`app-dir: ${inv.appDir}`) + lines.push(` ${mark(inv.hostExe)} ${inv.hostExe.name} (${kib(inv.hostExe.size)})`) + for (const e of inv.runtimeData) { + lines.push(` ${mark(e)} ${e.name} (${kib(e.size)})`) + } + lines.push(` top-level DLLs: ${inv.topLevelDlls.length}`) + for (const e of inv.topLevelDlls) { + lines.push(` - ${basename(e.name)} (${kib(e.size)})`) + } + lines.push( + ` ${mark(inv.daemonEntry)} daemon-entry: ${inv.daemonEntry.relFromUnpacked || '(none)'}` + ) + const np = inv.nodePty + lines.push(` node-pty: ${np.exists ? np.packageDir : '(none)'}`) + if (np.conptyNode) { + lines.push(` native: ${np.nativeRel} conpty.node (${kib(np.conptyNode.size)})`) + lines.push(` conpty.dll: ${mark(np.conptyDll)} OpenConsole.exe: ${mark(np.openConsole)}`) + } + return lines.join('\n') +} diff --git a/tools/daemon-relocation-spike/cli.mjs b/tools/daemon-relocation-spike/cli.mjs new file mode 100644 index 00000000000..227580d5ee3 --- /dev/null +++ b/tools/daemon-relocation-spike/cli.mjs @@ -0,0 +1,88 @@ +// Argument parsing + usage for the daemon-relocation spike. +// +// The spike answers an empirical question (see README): what is the MINIMAL +// set of files that must be copied out of a packaged win-unpacked build so a +// COPIED Orca.exe (ELECTRON_RUN_AS_NODE=1) can host the terminal daemon and a +// real ConPTY session while holding no open handles into the install dir. + +export const TIERS = ['full', 'no-gpu', 'minimal'] + +const USAGE = ` +daemon-relocation-spike — minimal relocated daemon-host file-set probe (Windows) + +Usage: + node tools/daemon-relocation-spike/spike.mjs --app-dir --work-dir [--tier ] + node tools/daemon-relocation-spike/spike.mjs --selftest + +Required (launch mode): + --app-dir Path to a packaged win-unpacked build (contains Orca.exe) + --work-dir Scratch dir for the copied host + logs (created, then removed) + +Options: + --tier File-set tier to copy: "full" (default), "no-gpu", "minimal" + full = Orca.exe + icudtl.dat + both snapshot blobs + + ALL top-level *.dll + daemon bundle + node-pty + no-gpu = full minus GPU/render DLLs (libEGL, libGLESv2, + vk_swiftshader, vulkan-1, d3dcompiler_47); + ffmpeg.dll kept + minimal = Orca.exe + icudtl.dat + both snapshots + + daemon bundle + node-pty only (no top-level DLLs) + --keep-work-dir Leave --work-dir on disk after the run (for inspection) + --selftest Validate arg parsing, tier defs, and the module-path filter + against synthetic inputs. No real build or launch. Exits + 0 on pass, non-zero on failure. + -h, --help Show this help + +Exit code is 0 only when the run PASSES: daemon ready, PTY echo round-trips the +nonce, and NO loaded module resolves under --app-dir. +` + +export function getUsage() { + return USAGE +} + +/** + * Parse argv (already sliced past `node script`). Returns a discriminated + * result: { help }, { selftest }, or { launch: {...} }. On a usage error + * returns { error } so the caller can print USAGE and exit non-zero. + */ +export function parseArgs(argv) { + if (argv.includes('-h') || argv.includes('--help')) { + return { help: true } + } + if (argv.includes('--selftest')) { + return { selftest: true } + } + + let appDir = '' + let workDir = '' + let tier = 'full' + let keepWorkDir = false + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--app-dir' && argv[i + 1]) { + appDir = argv[i + 1] + i++ + } else if (arg === '--work-dir' && argv[i + 1]) { + workDir = argv[i + 1] + i++ + } else if (arg === '--tier' && argv[i + 1]) { + tier = argv[i + 1] + i++ + } else if (arg === '--keep-work-dir') { + keepWorkDir = true + } else { + return { error: `Unknown or incomplete argument: ${arg}` } + } + } + + if (!appDir || !workDir) { + return { error: 'Both --app-dir and --work-dir are required' } + } + if (!TIERS.includes(tier)) { + return { error: `Invalid --tier "${tier}" (expected one of: ${TIERS.join(', ')})` } + } + + return { launch: { appDir, workDir, tier, keepWorkDir } } +} diff --git a/tools/daemon-relocation-spike/daemon-launch.mjs b/tools/daemon-relocation-spike/daemon-launch.mjs new file mode 100644 index 00000000000..74784db9fb7 --- /dev/null +++ b/tools/daemon-relocation-spike/daemon-launch.mjs @@ -0,0 +1,99 @@ +// Launches the copied Orca.exe as the daemon host (ELECTRON_RUN_AS_NODE=1) and +// waits for its {type:'ready'} IPC signal. Mirrors the real fork() options in +// src/main/daemon/daemon-init.ts (detached, ipc channel, ORCA_USER_DATA_PATH). + +import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { join } from 'node:path' + +/** + * Spawn the daemon host. Resolves { child, pid } once the daemon signals ready, + * or rejects on early exit / timeout. stdout+stderr are teed to files under + * workDir. The caller owns shutdown (SIGTERM `child`). + */ +export function launchDaemonHost(options) { + const { + hostExePath, + daemonEntryPath, + socketPath, + tokenPath, + workDir, + nodePtyNativeDir, + logFilePath, + readyTimeoutMs = 30000 + } = options + + const stdoutLog = createWriteStream(join(workDir, 'daemon-stdout.log')) + const stderrLog = createWriteStream(join(workDir, 'daemon-stderr.log')) + + const env = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_USER_DATA_PATH: workDir + } + // The current branch's node-pty patch resolves natives relative to its own + // dir, so this env var is inert there; set it anyway so the spike still works + // if run against a build that carries the ORCA_NODE_PTY_NATIVE_DIR patch. + if (nodePtyNativeDir) { + env.ORCA_NODE_PTY_NATIVE_DIR = nodePtyNativeDir + } + + // Why: --log-file makes the daemon write its session lifecycle events + // (session-created / session-exited / uncaught-exception-suppressed) to a + // file, which is the only window into ConPTY spawn failures that don't reach + // stdout/stderr. daemon-entry parses it as an optional Phase 0 flag. + const daemonArgs = [daemonEntryPath, '--socket', socketPath, '--token', tokenPath] + if (logFilePath) { + daemonArgs.push('--log-file', logFilePath) + } + + const child = spawn(hostExePath, daemonArgs, { + cwd: workDir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env + }) + child.stdout.pipe(stdoutLog) + child.stderr.pipe(stderrLog) + + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + finish(new Error(`daemon did not signal ready within ${readyTimeoutMs}ms`)) + }, readyTimeoutMs) + + function finish(err) { + if (settled) { + return + } + settled = true + clearTimeout(timer) + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + if (err) { + // Expose the still-running detached child so the caller can stop it; + // otherwise a ready-timeout leaks the daemon with no handle to kill it. + err.child = child + reject(err) + } else { + resolve({ child, pid: child.pid }) + } + } + function onMessage(msg) { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + finish(null) + } + } + function onError(err) { + finish(err) + } + function onExit(code, signal) { + finish(new Error(`daemon exited before ready (code=${code}, signal=${signal})`)) + } + + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) + }) +} diff --git a/tools/daemon-relocation-spike/host-copy.mjs b/tools/daemon-relocation-spike/host-copy.mjs new file mode 100644 index 00000000000..f95541a9176 --- /dev/null +++ b/tools/daemon-relocation-spike/host-copy.mjs @@ -0,0 +1,62 @@ +// Materializes a tier's copy plan into /daemon-host/, preserving the +// relative layout the daemon require-closure and node-pty native resolution +// expect. Returns the paths the launcher needs. + +import { cpSync, existsSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { HOST_EXE } from './app-inventory.mjs' +import { toPosixRelative } from './tier-file-set.mjs' + +export const HOST_SUBDIR = 'daemon-host' + +function destPath(hostRoot, destRel) { + return join(hostRoot, ...destRel.split('/')) +} + +/** + * Execute the copy plan. Returns { hostRoot, hostExePath, daemonEntryPath, + * nodePtyNativeDir, skipped } — nodePtyNativeDir is the / dir under the + * copied node-pty, offered to callers that want to set ORCA_NODE_PTY_NATIVE_DIR + * (see README: the current branch's node-pty patch does NOT read it, so it is + * belt-and-suspenders here). + */ +export function copyHost(inv, plan, workDir) { + const hostRoot = join(workDir, HOST_SUBDIR) + mkdirSync(hostRoot, { recursive: true }) + + const skipped = [] + for (const op of plan.ops) { + const dest = destPath(hostRoot, op.destRel) + if (!existsSync(op.sourcePath)) { + if (!op.optional) { + skipped.push(op.destRel) + } + continue + } + mkdirSync(dirname(dest), { recursive: true }) + // cpSync mirrors both files and directory trees; dereference symlinks so + // the copy holds no link back into the app dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true + }) + } + + const hostExePath = join(hostRoot, HOST_EXE) + // Both paths mirror the win-unpacked layout under hostRoot, so they resolve + // exactly as they do in the packaged app (relative to appDir). + const daemonEntryPath = inv.daemonEntry.exists + ? destPath(hostRoot, toPosixRelative(inv.appDir, inv.daemonEntry.path)) + : '' + + // The relocated node-pty native dir (build/Release or prebuilds/...), mirrored + // under the host root at node-pty's real win-unpacked-relative path. + let nodePtyNativeDir = '' + if (inv.nodePty.exists && inv.nodePty.nativeRel) { + const pkgRel = toPosixRelative(inv.appDir, inv.nodePty.packageDir) + nodePtyNativeDir = destPath(hostRoot, `${pkgRel}/${inv.nodePty.nativeRel}`) + } + + return { hostRoot, hostExePath, daemonEntryPath, nodePtyNativeDir, skipped } +} diff --git a/tools/daemon-relocation-spike/loaded-module-probe.mjs b/tools/daemon-relocation-spike/loaded-module-probe.mjs new file mode 100644 index 00000000000..07170a508a1 --- /dev/null +++ b/tools/daemon-relocation-spike/loaded-module-probe.mjs @@ -0,0 +1,85 @@ +// Probes a running daemon's loaded modules (via loaded-modules.ps1) and flags +// any that resolve under the original app dir — those would be locked during an +// NSIS update and defeat the relocation. + +import { spawnSync } from 'node:child_process' +import { join, win32 } from 'node:path' + +const HERE = import.meta.dirname +const POWERSHELL = 'powershell.exe' +const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'] + +// Case-insensitive, separator-normalized containment test. The inputs are always +// Windows module paths, so use win32 semantics explicitly — otherwise --selftest +// on a non-Windows host would treat `\` as a literal and every check would fail. +function normalizeForCompare(p) { + return win32 + .normalize(p) + .replace(/[\\/]+$/, '') + .toLowerCase() +} + +/** + * Pure filter: which of `modulePaths` live under `appDir`. Exported so selftest + * can validate the containment logic against synthetic inputs without a launch. + * Matches on a path-segment boundary so `C:\App` does not match `C:\Application`. + */ +export function findAppDirResidentModules(modulePaths, appDir) { + const needle = normalizeForCompare(appDir) + const prefix = `${needle}${win32.sep}` + return modulePaths.filter((raw) => { + const candidate = normalizeForCompare(raw) + return candidate === needle || candidate.startsWith(prefix) + }) +} + +/** + * Run the PowerShell probe for `pid`. Returns + * { found, mainModule, modules } or throws if PowerShell itself fails. + */ +export function probeLoadedModules(pid) { + const script = join(HERE, 'loaded-modules.ps1') + const result = spawnSync(POWERSHELL, [...PS_ARGS, '-File', script, '-ProcessId', String(pid)], { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024 + }) + if (result.error) { + throw new Error(`failed to spawn PowerShell probe: ${result.error.message}`) + } + // Check exit status before parsing: a non-zero exit that still wrote to stdout + // would otherwise surface as a bare JSON SyntaxError instead of the real error. + if (result.status !== 0) { + throw new Error(`loaded-modules.ps1 exited with status ${result.status}: ${result.stderr}`) + } + const trimmed = (result.stdout ?? '').trim() + if (!trimmed) { + throw new Error( + `loaded-modules.ps1 produced no output (exit ${result.status}): ${result.stderr}` + ) + } + return JSON.parse(trimmed) +} + +/** + * Full handle assessment for a running daemon: whether its main module is the + * copied host exe, and which loaded modules (if any) still live in the app dir. + * Returns a structured verdict; never throws for a clean/empty module list. + */ +export function assessDaemonHandles(pid, appDir, expectedHostExePath) { + const probe = probeLoadedModules(pid) + if (!probe.found) { + return { found: false, mainModuleOk: false, appDirModules: [], mainModule: null } + } + const modules = Array.isArray(probe.modules) ? probe.modules : [] + const appDirModules = findAppDirResidentModules(modules, appDir) + const mainModuleOk = + typeof probe.mainModule === 'string' && + normalizeForCompare(probe.mainModule) === normalizeForCompare(expectedHostExePath) + return { + found: true, + mainModule: probe.mainModule, + mainModuleOk, + moduleCount: modules.length, + appDirModules + } +} diff --git a/tools/daemon-relocation-spike/loaded-modules.ps1 b/tools/daemon-relocation-spike/loaded-modules.ps1 new file mode 100644 index 00000000000..98568134e9f --- /dev/null +++ b/tools/daemon-relocation-spike/loaded-modules.ps1 @@ -0,0 +1,44 @@ +# Emits the main-module path and every loaded module (DLL) path for a process, +# as a single JSON document, for the daemon-relocation spike's handle probe. +# +# Loaded DLLs are the update-time lock risk: the NSIS installer's CHECK_APP_RUNNING +# sweep force-closes processes whose image is under $INSTDIR, and files a live +# process maps cannot be replaced. A relocated host must load ZERO modules from +# the app dir. Data files (icudtl.dat, asar) are not memory-mapped as modules, so +# this probe covers the lock-critical set, not the entire open-handle set. +# +# PS 5.1 guard: $proc.Modules can be a single object (no .Count); @(...) forces +# an array so the enumeration and count are correct for one-module processes. + +param( + [Parameter(Mandatory = $true)] + [int]$ProcessId +) + +$ErrorActionPreference = 'Stop' + +try { + $proc = Get-Process -Id $ProcessId -ErrorAction Stop +} catch { + Write-Output (@{ found = $false; mainModule = $null; modules = @() } | ConvertTo-Json -Compress) + exit 0 +} + +$modulePaths = @() +foreach ($m in @($proc.Modules)) { + if ($m -and $m.FileName) { + $modulePaths += $m.FileName + } +} + +$mainModule = $null +if ($proc.MainModule -and $proc.MainModule.FileName) { + $mainModule = $proc.MainModule.FileName +} + +$result = @{ + found = $true + mainModule = $mainModule + modules = $modulePaths +} +Write-Output ($result | ConvertTo-Json -Compress -Depth 4) diff --git a/tools/daemon-relocation-spike/ndjson-client.mjs b/tools/daemon-relocation-spike/ndjson-client.mjs new file mode 100644 index 00000000000..8919823e54a --- /dev/null +++ b/tools/daemon-relocation-spike/ndjson-client.mjs @@ -0,0 +1,262 @@ +// Minimal purpose-built NDJSON client for the daemon wire protocol. +// +// Deliberately standalone (no electron / src imports) so the spike runs under +// plain node. Mirrors the handshake in src/main/daemon/daemon-server.ts: +// 1. read the token the server wrote to after it began listening +// 2. control socket: send hello {role:'control'}, await {type:'hello',ok:true} +// 3. stream socket: send hello {role:'stream'} with the SAME clientId +// 4. createOrAttach on control, then write() input, read 'data' events on stream + +import { connect } from 'node:net' +import { readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +function encodeNdjson(msg) { + return `${JSON.stringify(msg)}\n` +} + +// Split incoming bytes on newlines and dispatch each complete JSON line. +function makeLineReader(onMessage) { + let buffer = '' + return (chunk) => { + buffer += chunk.toString('utf8') + let idx = buffer.indexOf('\n') + while (idx !== -1) { + const line = buffer.slice(0, idx) + buffer = buffer.slice(idx + 1) + if (line.length > 0) { + onMessage(JSON.parse(line)) + } + idx = buffer.indexOf('\n') + } + } +} + +function connectSocket(socketPath, timeoutMs) { + return new Promise((resolve, reject) => { + const socket = connect(socketPath) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`connect timeout after ${timeoutMs}ms: ${socketPath}`)) + }, timeoutMs) + socket.once('connect', () => { + clearTimeout(timer) + resolve(socket) + }) + socket.once('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +// Send a hello and resolve once the server accepts (or reject on rejection). +function handshake(socket, hello) { + return new Promise((resolve, reject) => { + const read = makeLineReader((msg) => { + if (msg.type === 'hello') { + if (msg.ok) { + resolve(read) + } else { + reject(new Error(`hello rejected: ${msg.error}`)) + } + } + }) + socket.on('data', read) + socket.write(encodeNdjson(hello)) + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Strip CSI / OSC / two-char VT escapes so the marker regex can match executed +// PTY output that ConPTY interleaves with cursor-move and SGR color codes. +// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition +const ANSI_ESCAPE = /\[[0-9;?]*[ -/]*[@-~]|\][^]*?|[@-Z\\-_]/g + +function stripAnsi(text) { + return text.replace(ANSI_ESCAPE, '') +} + +// Race a promise against a timeout so a dead daemon can't wedge the failure +// path (an unanswered RPC would otherwise hang until the CI job limit). +function withTimeout(promise, ms, label) { + let timer + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * Connect a control+stream client, create a session, submit `command` + CR, and + * resolve once `expectRe` matches the accumulated PTY output (or reject on + * timeout). Returns { output, diagnostics } on success. On timeout/error the + * rejected Error carries `.diagnostics` and `.output` so the caller can tell + * "no session created" vs "session created but no output" vs "output arrived + * but the regex did not match". Always tears down its sockets. + */ +export async function runPtyEcho(options) { + const { + socketPath, + tokenPath, + protocolVersion, + command, + expectRe, + shellOverride, + connectTimeoutMs = 5000, + ioTimeoutMs = 20000, + // Why: PowerShell/PSReadLine needs a beat to initialize before it echoes + // typed input; writing the command the instant createOrAttach returns can + // race the shell's own startup so the keystrokes land before the prompt. + writeDelayMs = 400 + } = options + + const token = readFileSync(tokenPath, 'utf8').trim() + const clientId = randomUUID() + const sessionId = `spike-${randomUUID()}` + + // Everything we learn on the failure path, so the CI log can pinpoint where + // the ConPTY round-trip broke. + const diagnostics = { + createResponse: null, + ourDataFrames: 0, + otherDataFrames: 0, + exitEvents: [], + rawSample: '', + sessionsAtTimeout: null + } + + const control = await connectSocket(socketPath, connectTimeoutMs) + const stream = await connectSocket(socketPath, connectTimeoutMs) + const teardown = () => { + control.destroy() + stream.destroy() + } + + try { + const controlRead = await handshake(control, { + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'control' + }) + await handshake(stream, { + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'stream' + }) + + // Route control RPC responses by id. + const pending = new Map() + control.removeAllListeners('data') + control.on( + 'data', + makeLineReader((msg) => { + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id) + pending.delete(msg.id) + if (msg.ok) { + resolve(msg.payload) + } else { + reject(new Error(msg.error)) + } + } + }) + ) + // The initial controlRead consumed only the hello; discard it now. + void controlRead + + const rpc = (type, payload) => { + const id = randomUUID() + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + control.write(encodeNdjson({ id, type, payload })) + }) + } + + return await new Promise((resolve, reject) => { + let output = '' + let settled = false + + const rejectWith = (err) => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + err.diagnostics = diagnostics + err.output = output + reject(err) + } + + const timer = setTimeout(() => { + // On timeout, ask the daemon what sessions it thinks exist — this + // distinguishes "session never created / already exited" from "session + // alive but silent". + withTimeout(rpc('listSessions'), 2000, 'listSessions') + .then((payload) => { + diagnostics.sessionsAtTimeout = payload?.sessions ?? payload + }) + .catch((err) => { + diagnostics.sessionsAtTimeout = `listSessions error: ${err.message}` + }) + .finally(() => { + rejectWith(new Error(`pty echo timeout after ${ioTimeoutMs}ms`)) + }) + }, ioTimeoutMs) + + stream.removeAllListeners('data') + stream.on( + 'data', + makeLineReader((msg) => { + if (msg.type === 'event' && msg.event === 'data') { + const data = msg.payload?.data ?? '' + if (diagnostics.rawSample.length < 500) { + diagnostics.rawSample = (diagnostics.rawSample + data).slice(0, 500) + } + if (msg.sessionId === sessionId) { + diagnostics.ourDataFrames++ + output += data + // Test against the ANSI-stripped stream: ConPTY interleaves the + // executed marker with cursor-move / SGR codes. + if (expectRe.test(stripAnsi(output)) && !settled) { + settled = true + clearTimeout(timer) + resolve({ output, diagnostics }) + } + } else { + diagnostics.otherDataFrames++ + } + } else if (msg.type === 'event' && msg.event === 'exit') { + diagnostics.exitEvents.push({ sessionId: msg.sessionId, code: msg.payload?.code }) + } + }) + ) + + const createPayload = { sessionId, cols: 120, rows: 30 } + if (shellOverride) { + createPayload.shellOverride = shellOverride + } + rpc('createOrAttach', createPayload) + .then((payload) => { + diagnostics.createResponse = payload + return delay(writeDelayMs) + }) + // Submit with a lone CR: PSReadLine treats CRLF as a soft newline + // (multiline continuation) and leaves the command typed but unexecuted; + // a bare CR is Enter. + .then(() => rpc('write', { sessionId, data: `${command}\r` })) + .catch((err) => { + rejectWith(err instanceof Error ? err : new Error(String(err))) + }) + }) + } finally { + teardown() + } +} diff --git a/tools/daemon-relocation-spike/selftest.mjs b/tools/daemon-relocation-spike/selftest.mjs new file mode 100644 index 00000000000..52012671679 --- /dev/null +++ b/tools/daemon-relocation-spike/selftest.mjs @@ -0,0 +1,166 @@ +// Offline validation of the spike's pure logic: argument parsing, tier +// definitions, the tier -> copy-plan resolver, and the app-dir module filter. +// No real build, copy, or launch. `runSelftest()` returns true on all-pass. + +import { parseArgs, TIERS } from './cli.mjs' +import { TIER_DEFINITIONS, selectTierDlls, resolveTierFileSet, GPU_DLLS } from './tier-file-set.mjs' +import { findAppDirResidentModules } from './loaded-module-probe.mjs' + +function makeSyntheticInventory() { + const unpackedRoot = 'C:\\App\\resources\\app.asar.unpacked' + const f = (path) => ({ path, exists: true, size: 1 }) + return { + appDir: 'C:\\App', + unpackedRoot, + hostExe: { name: 'Orca.exe', ...f('C:\\App\\Orca.exe') }, + runtimeData: [ + { name: 'icudtl.dat', ...f('C:\\App\\icudtl.dat') }, + { name: 'snapshot_blob.bin', ...f('C:\\App\\snapshot_blob.bin') }, + { name: 'v8_context_snapshot.bin', ...f('C:\\App\\v8_context_snapshot.bin') } + ], + topLevelDlls: [ + { name: 'ffmpeg.dll', ...f('C:\\App\\ffmpeg.dll') }, + { name: 'libEGL.dll', ...f('C:\\App\\libEGL.dll') }, + { name: 'libGLESv2.dll', ...f('C:\\App\\libGLESv2.dll') }, + { name: 'vk_swiftshader.dll', ...f('C:\\App\\vk_swiftshader.dll') }, + { name: 'vulkan-1.dll', ...f('C:\\App\\vulkan-1.dll') }, + { name: 'd3dcompiler_47.dll', ...f('C:\\App\\d3dcompiler_47.dll') } + ], + daemonEntry: { + name: 'daemon-entry.js', + path: `${unpackedRoot}\\out\\main\\daemon-entry.js`, + exists: true, + size: 1, + relFromUnpacked: 'out/main/daemon-entry.js' + }, + // node-pty is packaged at resources/node_modules/node-pty — a SIBLING of + // app.asar.unpacked, NOT under it (see packaged-runtime-node-modules.cjs). + // The synthetic inventory reflects that so the copy-plan test catches any + // regression that mislocates it. + nodePty: { + exists: true, + packageDir: 'C:\\App\\resources\\node_modules\\node-pty', + nativeDir: 'C:\\App\\resources\\node_modules\\node-pty\\build\\Release', + nativeRel: 'build/Release', + conptyNode: { name: 'conpty.node', path: 'x', exists: true, size: 1 } + } + } +} + +function run() { + const failures = [] + const check = (name, cond) => { + if (!cond) { + failures.push(name) + } + } + + // ── Argument parsing ──────────────────────────────────────────────── + check('help flag', parseArgs(['--help']).help === true) + check('selftest flag', parseArgs(['--selftest']).selftest === true) + check('missing app-dir errors', Boolean(parseArgs(['--work-dir', 'w']).error)) + check('missing work-dir errors', Boolean(parseArgs(['--app-dir', 'a']).error)) + check( + 'bad tier errors', + Boolean(parseArgs(['--app-dir', 'a', '--work-dir', 'w', '--tier', 'x']).error) + ) + check('unknown arg errors', Boolean(parseArgs(['--nope']).error)) + const ok = parseArgs(['--app-dir', 'a', '--work-dir', 'w', '--tier', 'no-gpu', '--keep-work-dir']) + check('valid parse launch', Boolean(ok.launch)) + check('valid parse tier', ok.launch?.tier === 'no-gpu') + check('valid parse keep', ok.launch?.keepWorkDir === true) + check( + 'default tier full', + parseArgs(['--app-dir', 'a', '--work-dir', 'w']).launch?.tier === 'full' + ) + + // ── Tier definitions ──────────────────────────────────────────────── + check( + 'all tiers defined', + TIERS.every((t) => Boolean(TIER_DEFINITIONS[t])) + ) + const dlls = makeSyntheticInventory().topLevelDlls + check('full keeps all dlls', selectTierDlls(dlls, 'full').length === 6) + check('minimal keeps no dlls', selectTierDlls(dlls, 'minimal').length === 0) + const noGpu = selectTierDlls(dlls, 'no-gpu').map((d) => d.name) + check('no-gpu keeps ffmpeg', noGpu.includes('ffmpeg.dll')) + check('no-gpu drops libEGL', !noGpu.includes('libEGL.dll')) + check( + 'no-gpu drops all gpu dlls', + noGpu.every((n) => !GPU_DLLS.has(n.toLowerCase())) + ) + check('no-gpu count is 1', noGpu.length === 1) + + // ── Copy-plan resolution ──────────────────────────────────────────── + const inv = makeSyntheticInventory() + for (const tier of TIERS) { + const plan = resolveTierFileSet(inv, tier) + check(`${tier}: no warnings`, plan.warnings.length === 0) + const dests = plan.ops.map((o) => o.destRel) + check(`${tier}: has exe`, dests.includes('Orca.exe')) + check(`${tier}: has icu`, dests.includes('icudtl.dat')) + // destRel mirrors the full win-unpacked layout so the require-closure and + // node-pty native resolution work verbatim from the copy. + check( + `${tier}: has daemon entry`, + dests.includes('resources/app.asar.unpacked/out/main/daemon-entry.js') + ) + check(`${tier}: has node-pty dir`, dests.includes('resources/node_modules/node-pty')) + check( + `${tier}: node-pty op is a dir`, + plan.ops.find((o) => o.destRel === 'resources/node_modules/node-pty')?.kind === 'dir' + ) + } + const fullDests = resolveTierFileSet(inv, 'full').ops.map((o) => o.destRel) + check('full plan includes gpu dll', fullDests.includes('vulkan-1.dll')) + const minimalDests = resolveTierFileSet(inv, 'minimal').ops.map((o) => o.destRel) + check('minimal plan excludes ffmpeg', !minimalDests.includes('ffmpeg.dll')) + check('minimal plan excludes gpu dll', !minimalDests.includes('vulkan-1.dll')) + + // Missing required input surfaces a warning rather than throwing. + const brokenInv = { ...inv, nodePty: { exists: false, conptyNode: null } } + check('missing node-pty warns', resolveTierFileSet(brokenInv, 'full').warnings.length > 0) + + // ── Module-path filter ────────────────────────────────────────────── + const appDir = 'C:\\Users\\me\\AppData\\Local\\Programs\\orca' + const modules = [ + 'C:\\Users\\me\\AppData\\Local\\Programs\\orca\\Orca.exe', + 'C:\\Windows\\System32\\kernel32.dll', + 'C:\\Users\\me\\AppData\\Local\\orca-daemon-host\\Orca.exe' + ] + const resident = findAppDirResidentModules(modules, appDir) + check('detects app-dir module', resident.length === 1) + check('detects the right module', resident[0].toLowerCase().includes('programs\\orca\\orca.exe')) + // Sibling-prefix must NOT match (C:\...\orca vs C:\...\orca-daemon-host). + check( + 'sibling prefix not matched', + findAppDirResidentModules(['C:\\a\\orca-daemon-host\\x.dll'], 'C:\\a\\orca').length === 0 + ) + // Forward/back-slash + case normalization. + check( + 'slash + case normalized', + findAppDirResidentModules(['c:/a/ORCA/x.dll'], 'C:\\A\\orca').length === 1 + ) + check( + 'relocated host has zero app-dir modules', + findAppDirResidentModules( + ['C:\\work\\daemon-host\\Orca.exe', 'C:\\Windows\\System32\\ntdll.dll'], + appDir + ).length === 0 + ) + + return failures +} + +export function runSelftest() { + const failures = run() + if (failures.length === 0) { + console.log('selftest: PASS (all checks green)') + return true + } + console.error(`selftest: FAIL (${failures.length} check(s))`) + for (const name of failures) { + console.error(` - ${name}`) + } + return false +} diff --git a/tools/daemon-relocation-spike/spike.mjs b/tools/daemon-relocation-spike/spike.mjs new file mode 100644 index 00000000000..5ce1c9eaca8 --- /dev/null +++ b/tools/daemon-relocation-spike/spike.mjs @@ -0,0 +1,313 @@ +// daemon-relocation-spike — empirically finds the minimal file set that lets a +// COPIED Orca.exe (ELECTRON_RUN_AS_NODE=1), launched from outside the install +// dir, host the terminal daemon + a real ConPTY session while holding no open +// handles into the app dir. See README.md. +// +// Requires a packaged win-unpacked build (runs on Windows CI). Use --selftest to +// validate the pure logic anywhere without a build. + +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { join, relative } from 'node:path' +import { parseArgs, getUsage } from './cli.mjs' +import { inventoryAppDir, formatInventory } from './app-inventory.mjs' +import { resolveTierFileSet } from './tier-file-set.mjs' +import { copyHost } from './host-copy.mjs' +import { launchDaemonHost } from './daemon-launch.mjs' +import { runPtyEcho } from './ndjson-client.mjs' +import { assessDaemonHandles } from './loaded-module-probe.mjs' +import { runSelftest } from './selftest.mjs' + +const HERE = import.meta.dirname +const FALLBACK_PROTOCOL_VERSION = 18 + +// Read PROTOCOL_VERSION from the daemon source so the spike client never drifts +// out of sync with the running daemon's handshake. +function resolveProtocolVersion() { + try { + const typesPath = join(HERE, '..', '..', 'src', 'main', 'daemon', 'types.ts') + const match = readFileSync(typesPath, 'utf8').match(/PROTOCOL_VERSION\s*=\s*(\d+)/) + if (match) { + return Number(match[1]) + } + } catch { + // Fall through to the pinned default. + } + return FALLBACK_PROTOCOL_VERSION +} + +function makeSocketPath() { + if (process.platform === 'win32') { + return `\\\\?\\pipe\\orca-daemon-spike-${randomUUID().slice(0, 12)}` + } + return join(process.env.TMPDIR ?? '/tmp', `orca-daemon-spike-${randomUUID().slice(0, 12)}.sock`) +} + +/** Print a recursive listing of the copied daemon-host tree so the mirrored + * layout (daemon-entry + node-pty at their require-resolvable paths) is + * verifiable from the CI log. Depth-limited to keep output readable. */ +function printHostTree(hostRoot, maxDepth = 6) { + console.log(`\n--- copied daemon-host tree: ${hostRoot} ---`) + if (!existsSync(hostRoot)) { + console.log(' (missing)') + return + } + const walk = (dir, depth) => { + let entries = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + const full = join(dir, e.name) + const rel = relative(hostRoot, full).split('\\').join('/') + if (e.isDirectory()) { + console.log(` ${rel}/`) + if (depth < maxDepth) { + walk(full, depth + 1) + } + } else { + console.log(` ${rel}`) + } + } + } + walk(hostRoot, 0) +} + +/** Dump the tail of the daemon's captured stdout/stderr — the actual crash + * reason when it exits before ready. */ +function printDaemonLogs(workDir, tailLines = 60) { + for (const name of ['daemon.log', 'daemon-stdout.log', 'daemon-stderr.log']) { + const p = join(workDir, name) + console.log(`\n--- ${name} ---`) + try { + const text = readFileSync(p, 'utf8').trimEnd() + const lines = text.split('\n') + console.log(lines.slice(-tailLines).join('\n') || '(empty)') + } catch { + console.log('(unavailable)') + } + } +} + +/** Print the raw-frame diagnostics the client collects so a failed ConPTY + * round-trip is classifiable from the CI log alone. */ +function printEchoDiagnostics(d) { + if (!d) { + console.log(' (no diagnostics captured)') + return + } + console.log(` createOrAttach response: ${JSON.stringify(d.createResponse)}`) + console.log(` data frames (our session): ${d.ourDataFrames}`) + console.log(` data frames (other sessions): ${d.otherDataFrames}`) + console.log(` exit events: ${JSON.stringify(d.exitEvents)}`) + if (d.sessionsAtTimeout !== null) { + console.log(` listSessions at timeout: ${JSON.stringify(d.sessionsAtTimeout)}`) + } + const sample = d.rawSample || '' + console.log(` raw stream sample (${sample.length} chars): ${JSON.stringify(sample)}`) +} + +async function shutdownDaemon(child) { + if (!child || child.exitCode !== null) { + return + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, 5000) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + try { + child.kill('SIGTERM') + } catch { + clearTimeout(timer) + resolve() + } + }) +} + +async function runLaunch(opts) { + const { appDir, workDir, tier, keepWorkDir } = opts + const report = { + tier, + ready: false, + ptyEchoOk: false, + mainModuleOk: false, + appDirModules: [], + warnings: [], + error: null + } + + console.log(`\n=== daemon-relocation-spike (tier=${tier}) ===\n`) + + const inv = inventoryAppDir(appDir) + console.log(formatInventory(inv)) + console.log('') + + const plan = resolveTierFileSet(inv, tier) + report.warnings = plan.warnings + console.log(`tier: ${plan.label} (${plan.ops.length} copy ops)`) + if (plan.warnings.length > 0) { + for (const w of plan.warnings) { + console.error(` WARNING: ${w}`) + } + report.error = 'incomplete file set for chosen tier' + return report + } + + const { hostRoot, hostExePath, daemonEntryPath, nodePtyNativeDir, skipped } = copyHost( + inv, + plan, + workDir + ) + if (skipped.length > 0) { + console.error(` copy skipped (missing sources): ${skipped.join(', ')}`) + report.error = `required sources missing: ${skipped.join(', ')}` + return report + } + console.log(`copied host: ${hostExePath}`) + console.log(`daemon entry: ${daemonEntryPath}`) + console.log(`node-pty native dir: ${nodePtyNativeDir || '(none)'}`) + printHostTree(hostRoot) + console.log('') + + const socketPath = makeSocketPath() + const tokenPath = join(workDir, 'daemon.token') + const logFilePath = join(workDir, 'daemon.log') + const protocolVersion = resolveProtocolVersion() + + let child = null + try { + const launched = await launchDaemonHost({ + hostExePath, + daemonEntryPath, + socketPath, + tokenPath, + workDir, + nodePtyNativeDir, + logFilePath + }) + child = launched.child + report.ready = true + console.log(`daemon ready (pid=${launched.pid})`) + + // Probe loaded modules BEFORE shutdown — the daemon and its node-pty native + // are mapped at this point. + const handles = assessDaemonHandles(launched.pid, appDir, hostExePath) + report.mainModuleOk = handles.mainModuleOk + report.appDirModules = handles.appDirModules + console.log( + `handle probe: mainModuleOk=${handles.mainModuleOk} ` + + `modules=${handles.moduleCount ?? 0} appDirResident=${handles.appDirModules.length}` + ) + for (const m of handles.appDirModules) { + console.error(` APP-DIR MODULE (would lock during update): ${m}`) + } + + const nonce = randomUUID().slice(0, 8) + const marker = `SPIKE-OK-${nonce}` + // Match the marker only when it appears alone at line start (executed + // output), not inside the echoed `echo ` input line. + const expectRe = new RegExp(`(?:^|\\r?\\n)${marker}(?:\\r|\\n)`) + // `echo ` is shell-agnostic (cmd / powershell / pwsh / bash); force + // powershell.exe so the CI runner's ambient COMSPEC can't pick a shell that + // behaves differently under ConPTY. + const echo = await runPtyEcho({ + socketPath, + tokenPath, + protocolVersion, + command: `echo ${marker}`, + expectRe, + shellOverride: process.platform === 'win32' ? 'powershell.exe' : undefined + }) + report.ptyEchoOk = true + console.log(`pty echo: nonce round-tripped (${echo.output.length} bytes of output)`) + console.log('pty echo diagnostics:') + printEchoDiagnostics(echo.diagnostics) + } catch (err) { + // Recover the handle from a pre-ready rejection so `finally` can stop a + // daemon that started but never signaled ready. + child = child ?? err?.child ?? null + report.error = err instanceof Error ? err.message : String(err) + console.error(`\nFAILURE: ${report.error}`) + if (err && err.diagnostics) { + console.error('pty echo diagnostics:') + printEchoDiagnostics(err.diagnostics) + } + printDaemonLogs(workDir) + } finally { + await shutdownDaemon(child) + if (!keepWorkDir) { + try { + rmSync(workDir, { recursive: true, force: true }) + } catch { + // Best-effort cleanup; a locked file just means the relocation is + // incomplete, which the handle probe already reports. + } + } + } + + return report +} + +function printFinalReport(report) { + const pass = + report.ready && + report.ptyEchoOk && + report.appDirModules.length === 0 && + report.mainModuleOk && + !report.error + + console.log('\n=== FINAL REPORT ===') + console.log(` tier: ${report.tier}`) + console.log(` daemon ready: ${report.ready}`) + console.log(` pty echo ok: ${report.ptyEchoOk}`) + console.log(` main module = copy: ${report.mainModuleOk}`) + console.log(` app-dir modules: ${report.appDirModules.length}`) + for (const m of report.appDirModules) { + console.log(` - ${m}`) + } + if (report.error) { + console.log(` error: ${report.error}`) + } + console.log(` VERDICT: ${pass ? 'PASS' : 'FAIL'}\n`) + return pass +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)) + + if (parsed.help) { + console.log(getUsage()) + return 0 + } + if (parsed.error) { + console.error(`error: ${parsed.error}`) + console.error(getUsage()) + return 2 + } + if (parsed.selftest) { + return runSelftest() ? 0 : 1 + } + + if (process.platform !== 'win32') { + console.error( + 'launch mode requires Windows (ConPTY + loaded-module probe). Use --selftest elsewhere.' + ) + return 2 + } + + const report = await runLaunch(parsed.launch) + return printFinalReport(report) ? 0 : 1 +} + +main() + .then((code) => { + process.exitCode = code + }) + .catch((err) => { + console.error('unexpected error:', err) + process.exitCode = 1 + }) diff --git a/tools/daemon-relocation-spike/tier-file-set.mjs b/tools/daemon-relocation-spike/tier-file-set.mjs new file mode 100644 index 00000000000..312d9a6c8b4 --- /dev/null +++ b/tools/daemon-relocation-spike/tier-file-set.mjs @@ -0,0 +1,135 @@ +// Tier definitions as DATA, so trimming the copied file set is a config change +// rather than a code change. A tier resolves (given a discovered inventory) to a +// flat list of copy operations { sourcePath, destRel, kind }. + +import { dirname, join, relative, sep } from 'node:path' + +// GPU/render DLLs that a run-as-node Electron host plausibly never loads. The +// spike measures whether dropping them still yields a working ConPTY host. +// ffmpeg.dll is deliberately NOT here — it is kept in the no-gpu tier. +export const GPU_DLLS = new Set([ + 'libegl.dll', + 'libglesv2.dll', + 'vk_swiftshader.dll', + 'vulkan-1.dll', + 'd3dcompiler_47.dll' +]) + +// Each tier declares which top-level DLLs to include. The exe, runtime data +// blobs, daemon bundle, and node-pty are in every tier — they are the +// irreducible core (Orca.exe needs icu + snapshots even as node; the daemon +// needs its bundle; node-pty needs its native + conpty runtime). +export const TIER_DEFINITIONS = { + full: { label: 'TIER_FULL_RUNTIME', dlls: 'all' }, + 'no-gpu': { label: 'TIER_NO_GPU_DLLS', dlls: 'non-gpu' }, + minimal: { label: 'TIER_MINIMAL', dlls: 'none' } +} + +function isGpuDll(name) { + return GPU_DLLS.has(name.toLowerCase()) +} + +/** + * Which top-level DLL entries a tier keeps. Pure over the inventory's DLL list + * so selftest can exercise it without a real build. + */ +export function selectTierDlls(topLevelDlls, tier) { + const def = TIER_DEFINITIONS[tier] + if (!def || def.dlls === 'none') { + return [] + } + if (def.dlls === 'all') { + return topLevelDlls + } + return topLevelDlls.filter((e) => !isGpuDll(e.name)) +} + +/** + * A source file/dir's path relative to the win-unpacked root, normalized to '/'. + * + * Why relative to the APP DIR (not the app.asar.unpacked root): node-pty is + * packaged at resources/node_modules/node-pty (see + * config/packaged-runtime-node-modules.cjs), a SIBLING of app.asar.unpacked. + * The daemon resolves `require('node-pty')` by walking parent dirs up from + * resources/app.asar.unpacked/out/main/daemon-entry.js, which passes through + * resources/ and finds resources/node_modules/node-pty. Mirroring the full + * win-unpacked layout verbatim is the only copy that preserves that walk. + */ +export function toPosixRelative(appDir, absPath) { + return relative(appDir, absPath).split(sep).join('/') +} + +/** + * Build the ordered copy plan for a tier. Returns { ops, warnings } where each + * op is { sourcePath, destRel, kind: 'file' | 'dir' }. Every destRel mirrors the + * source's win-unpacked-relative path. Missing required inputs are surfaced as + * warnings rather than thrown, so the report stays complete. + */ +export function resolveTierFileSet(inv, tier) { + const ops = [] + const warnings = [] + const appDir = inv.appDir + + const addFile = (entry, requiredLabel, optional = false) => { + if (!entry || !entry.exists) { + if (requiredLabel) { + warnings.push(`missing required input: ${requiredLabel}`) + } + return + } + ops.push({ + sourcePath: entry.path, + destRel: toPosixRelative(appDir, entry.path), + kind: 'file', + optional + }) + } + + // Core: exe + runtime data blobs live next to Orca.exe in win-unpacked. + addFile(inv.hostExe, 'Orca.exe') + for (const entry of inv.runtimeData) { + addFile(entry, entry.name) + } + + // Top-level DLLs per tier. + for (const dll of selectTierDlls(inv.topLevelDlls, tier)) { + addFile(dll) + } + + // Daemon bundle: the entry, its sibling chunks/, and the unpacked + // out/package.json (CJS/ESM loader resolution). Mirror the layout verbatim. + if (inv.daemonEntry.exists && inv.unpackedRoot) { + addFile(inv.daemonEntry, 'daemon-entry.js') + const entryDir = dirname(inv.daemonEntry.path) + const chunksDir = join(entryDir, 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(inv.unpackedRoot, 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + } else { + warnings.push('missing required input: daemon-entry.js (+ chunks)') + } + + // node-pty package tree (native binding + conpty runtime), mirrored at its + // real win-unpacked path (resources/node_modules/node-pty). + if (inv.nodePty.exists && inv.nodePty.conptyNode) { + ops.push({ + sourcePath: inv.nodePty.packageDir, + destRel: toPosixRelative(appDir, inv.nodePty.packageDir), + kind: 'dir' + }) + } else { + warnings.push('missing required input: node-pty (with conpty.node)') + } + + return { ops, warnings, label: TIER_DEFINITIONS[tier].label } +} diff --git a/tools/win-update-e2e/README.md b/tools/win-update-e2e/README.md new file mode 100644 index 00000000000..973958a8b80 --- /dev/null +++ b/tools/win-update-e2e/README.md @@ -0,0 +1,215 @@ +# win-update-e2e — packaged NSIS update proof harness + +**Windows only.** Given two Orca Windows installers (version N and N+1), this +harness performs a real silent update and proves, with machine-checkable +assertions, what happens to the terminal **daemon** and its **sessions** across +the update — and whether any console/terminal window flashes. + +It is the Phase 0 "proof harness" deliverable from +[`docs/windows-terminal-update-survival-plan.md`](../../docs/windows-terminal-update-survival-plan.md). +It exists specifically because the July 2026 attempt shipped four broken RCs +without ever installing the packaged artifact (see +[`docs/windows-terminal-update-survival-postmortem.md`](../../docs/windows-terminal-update-survival-postmortem.md), +"Why verification missed every one of these"). Its design refuses to repeat +those verification failures: + +- **Window visibility is measured by window enumeration + owner/canary + attribution — never by conhost command-line heuristics.** The post-mortem + proved conhost flags invert with parent console state and `MainWindowHandle` + is `0` for Windows-Terminal-hosted consoles. See `window-enum.ps1`. +- **Interactivity is proven by execution, not by result-shape.** Typed commands + write sentinel **files**; the harness checks the files. A command that "runs + and returns correct output" but flashes a window is still caught, because the + window watch runs independently. +- **The daemon is identified by command-line marker, never by exe name.** With + `ELECTRON_RUN_AS_NODE` the daemon image is `Orca.exe`; a relocated Phase 1 + host may be a differently-named copied binary. See `daemon-processes.mjs`. +- **Each run uses an isolated userData dir** so its daemon's socket/token path + is unique and never collides with the many other daemons a dev box or CI + runner can host. + +## Usage + +``` +pnpm win-update-e2e --from --to --expect +# or download release assets via gh (one call each): +pnpm win-update-e2e --from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 --expect cold-restore +``` + +Or directly: `node tools/win-update-e2e/run.mjs --from ... --to ... --expect ...` + +### Flags + +| Flag | Meaning | +| --------------------------------------------- | ----------------------------------------------------------- | +| `--from ` / `--to ` | Local `orca-windows-setup.exe` for base (N) / update (N+1) | +| `--from-release ` / `--to-release ` | Download the setup asset from a GitHub release tag via `gh` | +| `--expect cold-restore \| survival` | Assertion profile (required) | +| `--install-dir ` | Isolated-install mode (see below) — install into `` | +| `--asset-pattern ` | gh asset glob (default `*windows-setup.exe`) | +| `--soak-seconds ` | Post-relaunch window-watch soak (default `180`) | +| `--keep-install` | Skip teardown/uninstall for debugging (ignored in isolated) | + +### Profiles + +- **`cold-restore`** — **today's** behavior and the baseline that must keep + passing against current `main`. The installer's path sweep kills the in-dir + daemon, so: old daemon PID is **dead**, a **fresh** daemon exists, scrollback + is cold-restored (best-effort), a new terminal is interactive, and **zero** + unexpected console/terminal windows appear. +- **`survival`** — the Phase 1 target. Daemon PID **unchanged** across the + update, marker process **still alive**, the pre-update session still + interactive (typed input echoes, Ctrl+C interrupts), and **zero** unexpected + windows. + +## Safety + +This harness installs, overwrites, and can uninstall a real app. Two guards +protect a developer's machine; a clean CI/VM is unaffected by either: + +- **Pre-existing app process → hard refusal.** If an Orca _app_ process (not a + daemon) is already running, the run aborts and prints the offending PIDs. The + harness never kills a process it did not start. +- **Pre-existing install → refusal unless `--allow-existing-install`.** If an + Orca install already exists under `%LOCALAPPDATA%\Programs`, the run refuses, + because installing N then N+1 would silently overwrite that build and leave + the `--to` version behind. Pass `--allow-existing-install` to proceed anyway. + +Uninstall behavior at teardown follows ownership: + +- **No pre-existing install** (harness fully owns it): teardown silently + uninstalls, unless `--keep-install`. +- **`--allow-existing-install` was used** (an install existed first): teardown + does **not** uninstall — removing a build the harness did not place would be + wrong. It prints a prominent note that the machine now has the `--to` version + and the prior build was not restored. + +## Isolated install mode (developer machines) + +On a clean CI/VM the harness installs into the default per-user location +(`%LOCALAPPDATA%\Programs\Orca`). A developer's box already has a real Orca there, +and the safety guards above would (correctly) refuse to run. **Isolated mode** +(`--install-dir `) lets the harness run on that box without disturbing the +real install. + +**The /D mechanism.** electron-builder's NSIS honors the standard NSIS `/D=` +override for the install *directory* (`node_modules/app-builder-lib/templates/nsis/multiUser.nsh`). +`/D` is special: it must be the **last** argument and **cannot be quoted**, so the +path must be absolute and **spaces-free** (validated by `validateInstallDir`). The +installer's kill-sweep only matches processes under its own `$INSTDIR`, so a +separate directory never touches the real install's app or daemon processes. + +**Why registry/shortcut backup-restore exists.** `/D` relocates *files only*. +Regardless of `/D`, the installer writes `InstallLocation` + the uninstall entry to +the **same per-user HKCU keys** as the real install +(`HKCU\Software\` and +`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\`, +`node_modules/app-builder-lib/templates/nsis/include/installer.nsh`) and rewrites the +Start Menu / Desktop shortcuts. Left hijacked, the user's **next real update would +install into the test directory**. So isolated mode, before installing: + +1. **Snapshots** the shared state (`registry-shortcut-backup.mjs`): `reg export`s + each existing key to `.reg` files, copies the Orca `*.lnk` shortcuts, and records + a manifest (which keys/shortcuts existed, the pre-run `InstallLocation`). +2. Runs the full install → update → assert proof against the isolated directory. +3. **Always restores** at teardown (a `try/finally` wraps everything after the + snapshot): `reg import`s keys that pre-existed, `reg delete`s keys the test + created, copies shortcuts back / deletes test-created ones, then **re-reads + `InstallLocation` and verifies** it matches the snapshot. On mismatch it prints a + loud block with the exact manual `reg import` command to recover. Isolated + teardown **always** uninstalls the test install (the harness owns the directory) + and removes the directory if empty — `--keep-install` is ignored. + +**Residual risk.** The backup/restore covers `InstallLocation`, the uninstall entry, +and the Orca shortcuts — the state that steers a future update and the user-visible +launchers. It does **not** attempt to snapshot auto-update state files under the real +install's `userData` (the harness uses an isolated `userData` throughout, so it never +writes there), and it cannot restore state if the machine loses power mid-teardown +(re-run with a valid `--install-dir` to let restore complete, or run the printed +`reg import` by hand). The `.reg` backups live under the run's temp dir until a +successful teardown removes it. + +**Example.** + +``` +pnpm win-update-e2e \ + --from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 \ + --expect cold-restore --install-dir C:\OrcaE2E +``` + +Read-only, touches nothing — print what isolated mode would snapshot on this machine: + +``` +node tools/win-update-e2e/registry-shortcut-backup.mjs +``` + +## What it does + +1. **Preflight** — assert win32; warn if elevated; **refuse** to run if a + pre-existing Orca _app_ process (not a daemon) is running that the harness + did not start (it is printed and the run aborts — the harness never kills a + user's processes); snapshot the baseline set of visible top-level windows. +2. **Install N** silently (` /S`) and locate `Orca.exe`. +3. **Launch** the installed app (Playwright `_electron`, isolated userData), + create ≥2 terminals, start a **marker** in one: a `powershell` loop that sets + a unique window-title **canary**, records its PID, and heartbeats a file. +4. **Record** the daemon PID (scoped pid-file + live-process scan), marker PID, + and session tab ids. +5. **Close** the app normally; verify the detached daemon is still alive. +6. **Start the window watch** — a background PowerShell loop polling visible + top-level windows every 500ms, diffing against baseline, recording every new + window (and title change) to a JSONL log through the update and soak. +7. **Install N+1** silently (the update). +8. **Relaunch** the app. +9. **Assert** per profile, then print a PASS/FAIL/INFO evidence table. +10. **Teardown** (unless `--keep-install`) — close app, kill only harness-created + processes, silent-uninstall. + +Exit code is `0` when every non-informational assertion passes, else `1` +(`2` for a CLI usage error). + +## Standalone instrument self-tests (no installers needed) + +Each probe module runs on its own so the harness's own instruments are testable: + +``` +# Opens a real transient console window and asserts the watch catches it: +node tools/win-update-e2e/window-watch.mjs --selftest + +# Read-only: list daemon processes + PID files on this machine: +node tools/win-update-e2e/daemon-processes.mjs [--user-data ] [--scope ] + +# Emit the current visible-window snapshot as JSON: +powershell -File tools/win-update-e2e/window-enum.ps1 +``` + +## Files + +| File | Responsibility | +| -------------------------- | ----------------------------------------------------------------------------- | +| `run.mjs` | Orchestrator + CLI entry | +| `cli-args.mjs` | Argument parsing / validation | +| `preflight.mjs` | win32/elevation checks, pre-existing-app refusal, baseline snapshot | +| `installer-steps.mjs` | Silent install/update/uninstall, exe discovery, gh download | +| `registry-shortcut-backup.mjs` | Isolated mode: snapshot/restore the shared HKCU keys + Orca shortcuts | +| `app-driver.mjs` | Playwright Electron launch + terminal driving (production-safe DOM selectors) | +| `interactivity-probes.mjs` | Sentinel-file echo / heartbeat / Ctrl+C probes | +| `daemon-processes.mjs` | Daemon PID discovery (command-line marker + pid file), scoped | +| `window-enum.ps1` | Shared visible-top-level-window enumerator (P/Invoke `EnumWindows`) | +| `window-watch.ps1` | Background baseline-diff watch loop → JSONL | +| `window-watch.mjs` | Node wrapper: start/stop watch, `--selftest`, baseline capture | +| `assertions.mjs` | Window-event classification + profile PASS/FAIL table | +| `platform-guard.mjs` | `assertWin32`, elevation detection | +| `powershell-runner.mjs` | Windows PowerShell 5.1 spawn helpers | + +## Known limitations + +- **Scrollback fidelity is best-effort.** A production build renders the + terminal with WebGL, so xterm text is not reliably in the DOM and the e2e + `SerializeAddon` is not exposed. When text cannot be read the check reports + `INFO` (unknown), never a false `FAIL`. +- **Daemon file log** does not exist yet in packaged builds (the fork's stdio is + suppressed). The "daemon log free of ERROR lines" assertion is `INFO` until + Phase 0 daemon logging lands, then it reads `/logs/daemon.log`. +- The harness assumes the packaged main honors `ORCA_E2E_USER_DATA_DIR` to + relocate userData; verify this against a real packaged build. diff --git a/tools/win-update-e2e/app-driver.mjs b/tools/win-update-e2e/app-driver.mjs new file mode 100644 index 00000000000..8f16a44645a --- /dev/null +++ b/tools/win-update-e2e/app-driver.mjs @@ -0,0 +1,357 @@ +// Drive the installed, packaged Orca app with Playwright's Electron driver. +// +// This targets a PRODUCTION build, so it must NOT depend on the e2e-only store +// exposure (window.__store / window.__paneManagers) — those exist only under a +// `--mode e2e` / VITE_EXPOSE_STORE build. Everything here uses ARIA/DOM +// selectors that ship in production (matching tests/e2e/helpers/terminal.ts and +// terminal-attention.spec.ts) and proves interactivity through filesystem +// sentinels rather than by reading the WebGL-rendered xterm buffer: +// - typed commands write a marker FILE; the harness checks the file. This +// proves keystrokes reached the shell AND executed — stronger, and robust, +// than scraping canvas-rendered terminal text. +// +// The long-running marker also sets a unique window-title canary and writes a +// heartbeat file every ~500ms: the canary lets the window watch attribute any +// real console flash to our child, and the heartbeat proves the session is +// live and streaming. + +import { _electron as electron } from '@stablyai/playwright-test' +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { seedFreshProfile } from './onboarding-profile.mjs' + +const NEW_TAB_BUTTON = { role: 'button', name: 'New tab' } +const NEW_TERMINAL_ITEM = /New Terminal/i +const NEW_WORKSPACE_BUTTON = { role: 'button', name: 'New workspace' } +const SORTABLE_TAB = '[data-testid="sortable-tab"]' +// Why: the layout mounts hidden duplicate panes; only the visible one is the +// live terminal, so target `:visible` to avoid focusing/measuring a hidden copy. +const TERMINAL_SURFACE_VISIBLE = '[data-terminal-tab-id]:visible' +const XTERM_CONTAINER_VISIBLE = '.xterm:visible' +const XTERM_INPUT = '.xterm-helper-textarea' + +/** + * Launch the installed Orca.exe. Pointing userDataDir at a harness-owned temp + * dir isolates this run's daemon (its socket/token path becomes unique), so + * daemon lookups never collide with other Orca installs/daemons on the box. + * Pass `seedProfile` (a buildFreshProfile object) to write orca-data.json + * BEFORE this launch — do so only on the FIRST launch, never before the + * post-update relaunch, or the persisted session under test is destroyed. + */ +export async function launchInstalledApp({ + exePath, + userDataDir, + seedProfile = null, + extraEnv = {} +}) { + const { ELECTRON_RUN_AS_NODE: _drop, ...cleanEnv } = process.env + mkdirSync(userDataDir, { recursive: true }) + if (seedProfile) { + seedFreshProfile(userDataDir, seedProfile) + } + const app = await electron.launch({ + executablePath: exePath, + args: [], + env: { + ...cleanEnv, + // Packaged main honors ORCA_E2E_USER_DATA_DIR to relocate userData + // (logs/daemon/terminal-history) under a controlled dir. + ORCA_E2E_USER_DATA_DIR: userDataDir, + ...extraEnv + } + }) + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + return { app, page } +} + +/** + * Best-effort diagnostics dump when driving fails: a screenshot, the visible + * body text, and whether the e2e store is exposed (it is not in production + * builds). Written under `dir` so CI can upload it and reveal the actual + * post-launch DOM state. Never throws. + */ +export async function captureFailureDiagnostics(page, dir, label) { + const out = {} + try { + mkdirSync(dir, { recursive: true }) + } catch { + return out + } + try { + await page.screenshot({ + path: path.join(dir, `${label}.png`), + fullPage: false, + timeout: 10_000 + }) + out.screenshot = `${label}.png` + } catch { + /* renderer may be unresponsive */ + } + try { + const info = await page.evaluate(() => ({ + hasStore: typeof window.__store, + title: document.title, + url: location.href, + bodyText: (document.body?.innerText ?? '').slice(0, 4000), + testIds: Array.from(document.querySelectorAll('[data-testid]')) + .map((el) => el.getAttribute('data-testid')) + .filter((v, i, a) => v && a.indexOf(v) === i) + .slice(0, 80), + buttons: Array.from(document.querySelectorAll('button,[role="button"]')) + .map((el) => (el.getAttribute('aria-label') || el.textContent || '').trim()) + .filter((v, i, a) => v && a.indexOf(v) === i) + .slice(0, 60) + })) + writeFileSync(path.join(dir, `${label}.json`), JSON.stringify(info, null, 2)) + out.info = info + } catch { + /* renderer may be unresponsive */ + } + return out +} + +/** Wait until the visible terminal surface and its xterm container are mounted. */ +export async function waitForTerminalReady(page, timeoutMs = 60_000) { + await page + .locator(TERMINAL_SURFACE_VISIBLE) + .first() + .waitFor({ state: 'visible', timeout: timeoutMs }) + await page + .locator(XTERM_CONTAINER_VISIBLE) + .first() + .waitFor({ state: 'visible', timeout: timeoutMs }) +} + +/** + * Get the app to an interactive terminal. + * - `allowCreate` true (first launch): if no terminal is visible, create a + * workspace from the seeded repo (or a new tab if a workspace already + * exists) — the drivable composer, not the native folder dialog. + * - `allowCreate` false (post-update relaunch): the session should be + * RESTORED, so only wait for the restored terminal — never create a second + * workspace (which would mask a broken restore). + */ +export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) { + const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first() + if (await visibleTerminal.isVisible().catch(() => false)) { + await waitForTerminalReady(page, timeoutMs) + return + } + if (!allowCreate) { + // Wait for the restored terminal to appear; a timeout here is a real + // (asserted) failure of session restore, not a driving gap. + await waitForTerminalReady(page, timeoutMs) + return + } + const newTab = page.getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name }).first() + if (await newTab.isVisible().catch(() => false)) { + await createTerminalTab(page) + return + } + await createWorkspaceFromSeededRepo(page, timeoutMs) + await waitForTerminalReady(page, timeoutMs) +} + +/** + * Drive the "New workspace" composer to create a worktree from the single + * seeded project. The composer is in-app DOM (unlike the native Add-Project + * dialog): open it, choose the "Blank Terminal" mode so the worktree opens a + * plain terminal (not an agent), then submit "Create worktree". + */ +async function createWorkspaceFromSeededRepo(page, timeoutMs) { + await page + .getByRole(NEW_WORKSPACE_BUTTON.role, { name: NEW_WORKSPACE_BUTTON.name }) + .first() + .click({ timeout: timeoutMs }) + // Choose the plain-terminal mode (best-effort — if it is already the default + // or the label differs, the create below still produces a worktree). + await page + .getByRole('button', { name: 'Blank Terminal' }) + .first() + .click({ timeout: 15_000 }) + .catch(() => {}) + // Submit. The create button's accessible name carries the shortcut hint + // ("Create worktreeCtrl"), so match by prefix; fall back to the documented + // Ctrl+Enter shortcut if the button is not directly clickable. + const created = await page + .getByRole('button', { name: /^Create worktree/ }) + .last() + .click({ timeout: 15_000 }) + .then(() => true) + .catch(() => false) + if (!created) { + await page.keyboard.press('Control+Enter') + } +} + +const OVERLAY_DISMISS_LABELS = ['Got it', 'Dismiss setup scripts', 'Dismiss tip', 'Dismiss update'] + +/** + * Best-effort dismissal of the modals/banners that appear after creating a + * worktree (a full-screen "Got it" feature-tip modal, the setup-script prompt, + * update banner) and intercept all input over the terminal. Loops because tips + * can appear in sequence. Never throws. + */ +export async function dismissOverlays(page, rounds = 3) { + for (let i = 0; i < rounds; i++) { + let acted = false + for (const name of OVERLAY_DISMISS_LABELS) { + const btn = page.getByRole('button', { name }).first() + if (await btn.isVisible().catch(() => false)) { + await btn.click({ timeout: 3_000 }).catch(() => {}) + acted = true + } + } + await page.keyboard.press('Escape').catch(() => {}) + if (!acted) { + return + } + await page.waitForTimeout(400) + } +} + +/** Create a new terminal tab via the New tab menu. Returns the count after. */ +export async function createTerminalTab(page) { + await dismissOverlays(page, 1) + const before = await page.locator(SORTABLE_TAB).count() + await page + .getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name }) + .first() + .click({ force: true }) + await page.getByRole('menuitem', { name: NEW_TERMINAL_ITEM }).first().click({ force: true }) + await page.waitForFunction( + ({ selector, prev }) => document.querySelectorAll(selector).length > prev, + { selector: SORTABLE_TAB, prev: before }, + { timeout: 10_000 } + ) + await waitForTerminalReady(page) + return page.locator(SORTABLE_TAB).count() +} + +/** Cheap session identifiers: the rendered tab ids. */ +export async function listTabIds(page) { + return page + .locator(SORTABLE_TAB) + .evaluateAll((tabs) => + tabs.map((t) => t.getAttribute('data-tab-id')).filter((id) => Boolean(id)) + ) +} + +/** + * Focus the live terminal so keystrokes reach the shell. Clicking the visible + * xterm surface is what actually gives xterm keyboard focus — focusing the + * off-screen helper textarea alone does not, which is why typed input was being + * dropped. Click the pane, then focus the helper textarea as a belt-and-braces. + */ +export async function focusActiveTerminal(page) { + // A feature-tip modal can appear late and swallow keystrokes; clear any before + // focusing so typed commands actually reach the shell. + for (const name of OVERLAY_DISMISS_LABELS) { + const btn = page.getByRole('button', { name }).first() + if (await btn.isVisible().catch(() => false)) { + await btn.click({ timeout: 2_000 }).catch(() => {}) + } + } + const surface = page.locator(TERMINAL_SURFACE_VISIBLE).first() + await surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }).catch(() => {}) + // Scope the helper textarea to the visible surface so focus can't land on a + // hidden duplicate pane's textarea (which would silently swallow keystrokes). + const input = surface.locator(XTERM_INPUT).last() + await input.focus().catch(() => {}) + return input +} + +/** Type a line and submit it (Enter → \r submits in the shell). */ +export async function typeLine(page, text) { + await focusActiveTerminal(page) + await page.keyboard.type(text) + await page.keyboard.press('Enter') +} + +/** Send Ctrl+C to the active terminal. */ +export async function sendCtrlC(page) { + await focusActiveTerminal(page) + await page.keyboard.press('Control+C') +} + +/** + * Run a PowerShell command inside the active terminal by invoking a nested + * powershell.exe. The command is wrapped in double quotes for the OUTER + * interactive shell (also pwsh), which would otherwise expand `$var`, `$(...)` + * and consume backticks before the nested shell sees them — so escape backticks, + * quotes, and `$`. Without the `$` escape, `while($true)` reaches the nested + * shell as `while(True)` and never runs (the bug that silently broke every + * loop/heartbeat probe while simple `$`-free commands worked). + */ +export async function runShellCommand(page, psCommand) { + const escaped = psCommand.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$') + await typeLine(page, `powershell.exe -NoProfile -NonInteractive -Command "${escaped}"`) +} + +/** + * Start the long-running marker in the active terminal: sets the canary window + * title, records its own PID, and heartbeats a file every 500ms. Returns the + * command string (the caller reads the pid file to learn the marker PID). + */ +export async function startMarker(page, { canary, pidFile, heartbeatFile }) { + const script = [ + `$host.UI.RawUI.WindowTitle='${canary}'`, + `Set-Content -LiteralPath '${pidFile}' -Value $PID`, + `while($true){ [System.IO.File]::WriteAllText('${heartbeatFile}',(Get-Date).ToString('o')); Start-Sleep -Milliseconds 500 }` + ].join('; ') + await runShellCommand(page, script) + return script +} + +/** + * Best-effort read of the active terminal's visible text, for the cold-restore + * scrollback fidelity check. Prefers the SerializeAddon when the build happens + * to expose paneManagers; falls back to DOM rows (populated only under the DOM + * renderer, so this may be empty under WebGL — hence best-effort). + */ +export async function readTerminalTextBestEffort(page) { + return page.evaluate(() => { + const managers = window.__paneManagers + if (managers && typeof managers.forEach === 'function') { + let out = '' + managers.forEach((m) => { + const pane = m.getActivePane?.() ?? m.getPanes?.()[0] + const text = pane?.serializeAddon?.serialize?.() + if (text) { + out += text + } + }) + if (out) { + return out + } + } + return Array.from(document.querySelectorAll('.xterm-rows')) + .map((el) => el.textContent ?? '') + .join('\n') + }) +} + +/** + * Close the app gracefully; force-kill its process tree on timeout. Mirrors + * tests/e2e/helpers/electron-process-shutdown.ts so the daemon (detached) is + * left alive exactly as a normal quit would. + */ +export async function closeApp(app, timeoutMs = 10_000) { + const proc = app.process() + try { + await Promise.race([ + app.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), timeoutMs)) + ]) + } catch { + if (proc?.pid) { + try { + execFileSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }) + } catch { + /* already gone */ + } + } + } +} diff --git a/tools/win-update-e2e/assertions.mjs b/tools/win-update-e2e/assertions.mjs new file mode 100644 index 00000000000..20db79e48c5 --- /dev/null +++ b/tools/win-update-e2e/assertions.mjs @@ -0,0 +1,186 @@ +// Profile assertions and the final PASS/FAIL evidence table. +// +// Two profiles: +// cold-restore — TODAY's behavior. The installer's path sweep kills the +// in-dir daemon, so the old daemon PID must be DEAD, a fresh daemon must +// exist, scrollback is cold-restored (best-effort), a new terminal is +// interactive, and NO unexpected console/terminal windows appear. +// survival — Phase 1 target. The daemon PID is UNCHANGED across the update, +// the marker process is still alive, the pre-update session is still +// interactive (echo + Ctrl+C), and NO unexpected windows appear. + +const CONSOLE_HOST_PROCESSES = new Set([ + 'powershell', + 'pwsh', + 'cmd', + 'conhost', + 'windowsterminal', + 'openconsole' +]) + +// The app's own windows are expected on relaunch and never count as a flash. +const APP_OWNER_PROCESSES = new Set(['orca', 'electron']) + +/** + * Split window-watch events into unexpected flashes vs benign. Unexpected = + * (a) any window whose title contains the run's canary — a real flash of our + * marker child, which must never get its own window — or (b) any new + * console/terminal-host window that is not the app itself. Attribution is by + * title + owner process only (never conhost command-line heuristics). + */ +export function classifyWindowEvents(events, { canary }) { + const unexpected = [] + for (const event of events) { + const title = typeof event.title === 'string' ? event.title : '' + const owner = (event.processName ?? '').toLowerCase() + const canaryHit = canary && title.includes(canary) + const consoleHit = CONSOLE_HOST_PROCESSES.has(owner) && !APP_OWNER_PROCESSES.has(owner) + if (canaryHit || consoleHit) { + unexpected.push({ ...event, reason: canaryHit ? 'canary-title' : 'console-host' }) + } + } + return { unexpected } +} + +function assertion(name, pass, expected, actual, detail = '') { + return { name, pass, expected, actual, detail } +} + +/** Build the ordered assertion list for the run's profile. */ +export function buildAssertions(ctx) { + const { unexpected } = classifyWindowEvents(ctx.watchEvents ?? [], { canary: ctx.canary }) + const windowAssertion = assertion( + 'zero unexpected console/terminal windows', + unexpected.length === 0, + '0 windows', + `${unexpected.length} windows`, + unexpected.map((u) => `${u.processName}:"${u.title}" (${u.reason})`).join('; ') + ) + + const common = [windowAssertion, daemonLogAssertion(ctx)] + + return ctx.profile === 'survival' + ? [...survivalAssertions(ctx), ...common] + : [...coldRestoreAssertions(ctx), ...common] +} + +function survivalAssertions(ctx) { + const samePid = + ctx.preDaemonPid != null && ctx.preDaemonPid === ctx.postDaemonPid && ctx.postDaemonAlive + return [ + assertion( + 'daemon PID unchanged across update', + samePid, + `pid ${ctx.preDaemonPid} still alive`, + `post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})` + ), + assertion( + 'marker process still alive', + Boolean(ctx.markerAliveAfter), + `marker pid ${ctx.markerPid} alive`, + String(ctx.markerAliveAfter) + ), + assertion( + 'pre-update session streams (heartbeat advanced)', + Boolean(ctx.heartbeatAdvancedAfterUpdate), + 'heartbeat mtime advanced post-update', + String(ctx.heartbeatAdvancedAfterUpdate) + ), + assertion( + 'typed input echoes in pre-update session', + Boolean(ctx.echoObserved), + 'echo sentinel file written', + String(ctx.echoObserved) + ), + assertion( + 'Ctrl+C interrupts marker loop', + Boolean(ctx.ctrlCInterrupted), + 'heartbeat stopped + post-interrupt sentinel written', + String(ctx.ctrlCInterrupted) + ) + ] +} + +function coldRestoreAssertions(ctx) { + const freshDaemon = + ctx.postDaemonPid != null && ctx.postDaemonPid !== ctx.preDaemonPid && ctx.postDaemonAlive + return [ + assertion( + 'old daemon PID is dead after update', + ctx.preDaemonAliveAfter === false, + `pid ${ctx.preDaemonPid} dead`, + `alive: ${ctx.preDaemonAliveAfter}` + ), + assertion( + 'fresh daemon exists after relaunch', + freshDaemon, + 'new daemon pid, alive', + `post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})` + ), + // Best-effort: WebGL renderer may hide buffer text from DOM scraping, so a + // null result is reported as informational (pass=null), not a failure. + assertion( + 'previous terminals show restored scrollback (best-effort)', + ctx.scrollbackRestored === null ? null : ctx.scrollbackRestored, + 'prior output text present after restore', + ctx.scrollbackRestored === null ? 'unknown (renderer opaque)' : String(ctx.scrollbackRestored) + ), + assertion( + 'new terminal is interactive (typed input echoes)', + Boolean(ctx.echoObserved), + 'echo sentinel file written', + String(ctx.echoObserved) + ), + assertion( + 'Ctrl+C kills a sleep loop in new terminal', + Boolean(ctx.ctrlCInterrupted), + 'loop interrupted + post-interrupt sentinel written', + String(ctx.ctrlCInterrupted) + ) + ] +} + +function daemonLogAssertion(ctx) { + // The daemon has no file log today (stdio is suppressed in packaged forks). + // Treat "no log present" as informational; when a log IS present (Phase 0 + // observability), assert it is free of ERROR lines. + if (!ctx.daemonLog) { + return assertion( + 'daemon log free of fatal records', + null, + 'no fatal/invalid-token records', + 'no daemon log present (informational)' + ) + } + // Only genuinely-bad records count (fatal uncaught exceptions, invalid-token + // hello rejections). Benign suppressed native-PTY exceptions are reported as + // context but never affect pass/fail. + const errorLines = ctx.daemonLog.errorLines ?? [] + const suppressed = ctx.daemonLog.suppressedCount ?? 0 + const suppressedNote = suppressed > 0 ? ` (${suppressed} benign suppressed, ignored)` : '' + return assertion( + 'daemon log free of fatal records', + errorLines.length === 0, + 'no fatal/invalid-token records', + `${errorLines.length} fatal record(s)${suppressedNote}`, + errorLines.slice(0, 3).join(' | ') + ) +} + +/** True only if every non-informational assertion passed. */ +export function allPassed(assertions) { + return assertions.every((a) => a.pass === true || a.pass === null) +} + +/** Render the assertion list as an aligned PASS/FAIL/INFO table. */ +export function renderTable(assertions) { + const symbol = (pass) => (pass === true ? 'PASS' : pass === false ? 'FAIL' : 'INFO') + const nameWidth = Math.max(...assertions.map((a) => a.name.length), 10) + const lines = assertions.map((a) => { + const detail = a.detail ? ` — ${a.detail}` : '' + return ` [${symbol(a.pass)}] ${a.name.padEnd(nameWidth)} expected: ${a.expected}; actual: ${a.actual}${detail}` + }) + const failed = assertions.filter((a) => a.pass === false).length + const header = `\n===== win-update-e2e assertions (${failed === 0 ? 'ALL PASS' : `${failed} FAILED`}) =====` + return [header, ...lines, ''].join('\n') +} diff --git a/tools/win-update-e2e/cli-args.mjs b/tools/win-update-e2e/cli-args.mjs new file mode 100644 index 00000000000..ed50412314e --- /dev/null +++ b/tools/win-update-e2e/cli-args.mjs @@ -0,0 +1,201 @@ +// Argument parsing for `node run.mjs`. +// +// Two installer sources are accepted per side: a local path (--from/--to) or a +// GitHub release tag (--from-release/--to-release) that the harness downloads +// via `gh release download`. Exactly one profile (--expect) is required. + +import { existsSync, readdirSync } from 'node:fs' +import path from 'node:path' + +const VALID_PROFILES = new Set(['cold-restore', 'survival']) + +const USAGE = ` +win-update-e2e — packaged NSIS update proof harness (Windows only) + +Usage: + node tools/win-update-e2e/run.mjs --from --to --expect [options] + node tools/win-update-e2e/run.mjs --from-release --to-release --expect + +Installer source (version N, then N+1) — path or release tag on each side: + --from Local orca-windows-setup.exe for the base version (N) + --to Local orca-windows-setup.exe for the update (N+1) + --from-release Download N's setup asset via gh (e.g. v1.4.124-rc.9) + --to-release Download N+1's setup asset via gh + +Required: + --expect Assertion profile: "cold-restore" or "survival" + cold-restore = today's behavior (daemon killed by the + installer sweep, app cold-restores scrollback, no + flashing). survival = Phase 1 target (daemon PID + unchanged, sessions still interactive). + +Options: + --install-dir Isolated-install mode: install the test build into + instead of the default per-user location, + leaving a developer's REAL Orca install untouched. + The path must be absolute and contain NO SPACES (the + NSIS /D override cannot be quoted), must not be the + default install location, and must not point at a + non-empty directory that is not a prior harness + install. Isolated mode snapshots and restores the + shared per-user registry keys + shortcuts at teardown + so the real install's "next update" target is + preserved. See README "Isolated install mode". + --allow-existing-install Proceed even if an Orca install already exists. The + run overwrites it with the --from/--to versions and + leaves the --to version installed (your prior build + is NOT restored). Without this flag the harness + refuses to run when an install exists, to protect a + developer's real Orca. Clean machines (CI/VM) never + need it. Ignored in --install-dir mode, which never + touches the real install. + --keep-install Skip teardown/uninstall (leaves the app installed) + --asset-pattern gh release asset glob (default: *windows-setup.exe) + --soak-seconds Post-relaunch window watch duration (default: 180) + -h, --help Show this help +` + +export function parseArgs(argv) { + if (argv.includes('-h') || argv.includes('--help')) { + return { help: true, usage: USAGE } + } + + const opts = { + from: takeValue(argv, '--from'), + to: takeValue(argv, '--to'), + fromRelease: takeValue(argv, '--from-release'), + toRelease: takeValue(argv, '--to-release'), + expect: takeValue(argv, '--expect'), + assetPattern: takeValue(argv, '--asset-pattern') ?? '*windows-setup.exe', + soakSeconds: Number(takeValue(argv, '--soak-seconds') ?? '180'), + installDir: takeValue(argv, '--install-dir'), + keepInstall: argv.includes('--keep-install'), + allowExistingInstall: argv.includes('--allow-existing-install'), + usage: USAGE + } + + // Distinguish "--install-dir omitted" from "--install-dir with no value": the + // latter must fail rather than silently fall back to a non-isolated install. + const errors = validate(opts, argv.includes('--install-dir')) + return { ...opts, errors } +} + +/** Default per-user oneClick install location: %LOCALAPPDATA%\Programs\Orca. */ +function defaultInstallDir() { + const localAppData = + process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Local') + return path.join(localAppData, 'Programs', 'Orca') +} + +/** True if `child` is equal to, inside, or an ancestor of `parent` (case-insensitive). */ +function pathsOverlap(a, b) { + const na = path + .resolve(a) + .replace(/[\\/]+$/, '') + .toLowerCase() + const nb = path + .resolve(b) + .replace(/[\\/]+$/, '') + .toLowerCase() + if (na === nb) { + return true + } + return na.startsWith(`${nb}\\`) || nb.startsWith(`${na}\\`) +} + +/** A prior harness install directory carries both the app exe and its uninstaller. */ +function looksLikeHarnessInstall(dir) { + return existsSync(path.join(dir, 'Orca.exe')) && existsSync(path.join(dir, 'Uninstall Orca.exe')) +} + +/** + * Validate --install-dir for isolated-install mode. The NSIS /D override must be + * the last, unquoted argument, so the path cannot contain spaces. It must also + * not overlap the default install location (that would defeat isolation) and + * must not clobber an unrelated non-empty directory. + */ +export function validateInstallDir(installDir) { + const errors = [] + if (!path.isAbsolute(installDir)) { + errors.push(`--install-dir must be an absolute path (got "${installDir}")`) + return errors + } + if (/\s/.test(installDir)) { + errors.push( + `--install-dir must not contain spaces (got "${installDir}"). The NSIS installer's ` + + `/D path override must be the last, UNQUOTED argument, so a path with spaces cannot ` + + `be passed. Choose a spaces-free location (e.g. C:\\OrcaE2E).` + ) + } + if (pathsOverlap(installDir, defaultInstallDir())) { + errors.push( + `--install-dir "${installDir}" overlaps the default install location ` + + `"${defaultInstallDir()}". Isolated mode must target a separate directory so the ` + + `real install is never touched.` + ) + } + if (existsSync(installDir)) { + let entries = [] + try { + entries = readdirSync(installDir) + } catch (err) { + // Fail closed: an unreadable existing directory must not be treated as + // empty/safe to overwrite. + errors.push( + `--install-dir "${installDir}" could not be read (${err.message}). ` + + `Refusing to treat an unreadable directory as safe to overwrite.` + ) + return errors + } + if (entries.length > 0 && !looksLikeHarnessInstall(installDir)) { + errors.push( + `--install-dir "${installDir}" is a non-empty directory that does not look like a ` + + `prior harness install (no Orca.exe + "Uninstall Orca.exe"). Refusing to overwrite ` + + `unrelated files. Point at an empty or non-existent directory.` + ) + } + } + return errors +} + +function validate(opts, installDirFlagPresent) { + const errors = [] + if (!opts.from && !opts.fromRelease) { + errors.push('Missing base installer: pass --from or --from-release ') + } + if (opts.from && opts.fromRelease) { + errors.push('Pass only one of --from / --from-release') + } + if (!opts.to && !opts.toRelease) { + errors.push('Missing update installer: pass --to or --to-release ') + } + if (opts.to && opts.toRelease) { + errors.push('Pass only one of --to / --to-release') + } + if (!opts.expect) { + errors.push('Missing --expect ') + } else if (!VALID_PROFILES.has(opts.expect)) { + errors.push(`Invalid --expect "${opts.expect}" (expected cold-restore or survival)`) + } + if (!Number.isFinite(opts.soakSeconds) || opts.soakSeconds < 0) { + errors.push('--soak-seconds must be a non-negative number') + } + if (installDirFlagPresent && opts.installDir === undefined) { + errors.push('--install-dir requires a path value') + } else if (opts.installDir !== undefined) { + errors.push(...validateInstallDir(opts.installDir)) + } + return errors +} + +function takeValue(argv, flag) { + const idx = argv.indexOf(flag) + if (idx < 0) { + return undefined + } + const value = argv[idx + 1] + if (value === undefined || value.startsWith('--')) { + return undefined + } + return value +} diff --git a/tools/win-update-e2e/daemon-processes.mjs b/tools/win-update-e2e/daemon-processes.mjs new file mode 100644 index 00000000000..cfd9c5d41b6 --- /dev/null +++ b/tools/win-update-e2e/daemon-processes.mjs @@ -0,0 +1,173 @@ +// Identify and inspect the Orca terminal daemon on Windows. +// +// The daemon is forked with ELECTRON_RUN_AS_NODE=1, so on Windows its process +// image is Orca.exe (the Electron binary running as plain Node) — it CANNOT be +// matched by executable name. The only reliable discriminators are the +// command-line markers the fork always passes: the daemon entry script +// (daemon-entry.js) and its --socket / --token arguments. See +// src/main/daemon/daemon-init.ts (createOutOfProcessLauncher). +// +// The daemon also writes a PID file at /daemon/daemon-v.pid whose +// JSON carries { pid, startedAtMs, entryPath, appVersion } — the appVersion +// field lets the harness prove whether a post-update daemon was re-forked by +// the new build (cold-restore) or is the same process (survival). + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { assertWin32 } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' + +const DAEMON_ENTRY_MARKER = 'daemon-entry.js' + +/** Default packaged userData root on Windows: %APPDATA%\Orca. */ +export function defaultUserDataDir() { + const appData = + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming') + return path.join(appData, 'Orca') +} + +/** + * Find live daemon processes by scanning Win32_Process command lines for the + * daemon-entry.js marker. Returns [{ pid, ppid, name, commandLine }]. + * + * A developer box (and a busy CI runner) can host many unrelated daemons — one + * per worktree/profile, plus lingering hosts from reverted builds. Pass + * `scope` (a substring of the harness's own userData/token/socket path) to + * match ONLY the daemon this harness's app instance owns. Omit it for a + * machine-wide listing. + */ +export function findDaemonProcesses(scope = '') { + assertWin32('daemon-processes') + // Match by command-line marker only, never by exe name: with + // ELECTRON_RUN_AS_NODE the daemon's image is Orca.exe today but a relocated + // Phase 1 host may run from a differently-named copied binary. @() around the + // filtered result guards the PS 5.1 single-item unwrap — one match must still + // serialize as an array or the JS side sees an object and .length explodes. + // This exact class caused a production incident. + const command = [ + `$procs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |`, + ` Where-Object { $_.CommandLine -and $_.CommandLine -match 'daemon-entry\\.js' })`, + `$out = @($procs | ForEach-Object {`, + ` [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; commandLine = $_.CommandLine } })`, + `ConvertTo-Json -InputObject @{ processes = $out } -Depth 4 -Compress` + ].join('\n') + + const parsed = runJsonCommand(command) + const scopeNeedle = scope.toLowerCase() + return normalizeArray(parsed.processes).filter( + (p) => + typeof p.commandLine === 'string' && + p.commandLine.includes(DAEMON_ENTRY_MARKER) && + (scopeNeedle === '' || p.commandLine.toLowerCase().includes(scopeNeedle)) + ) +} + +/** + * Read all daemon PID files under /daemon (daemon-v*.pid). Globbing + * the protocol-versioned name keeps this correct across PROTOCOL_VERSION bumps. + * Returns [{ file, pid, startedAtMs, entryPath, appVersion }]. + */ +export function readDaemonPidFiles(userDataDir = defaultUserDataDir()) { + const daemonDir = path.join(userDataDir, 'daemon') + if (!existsSync(daemonDir)) { + return [] + } + const records = [] + for (const entry of readdirSync(daemonDir)) { + if (!entry.startsWith('daemon-v') || !entry.endsWith('.pid')) { + continue + } + const filePath = path.join(daemonDir, entry) + // Read once: a PID file can vanish between readdir and here, and re-reading + // it in the catch path would crash discovery on a single stale file. + let raw = '' + try { + raw = readFileSync(filePath, 'utf8').trim() + const parsed = JSON.parse(raw) + records.push({ file: filePath, ...parsed }) + } catch { + // Legacy/partial pid files may hold a bare integer. + const pid = Number(raw) + if (Number.isInteger(pid)) { + records.push({ file: filePath, pid }) + } + } + } + return records +} + +/** True if a PID currently maps to a live process. */ +export function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + const { stdout } = runCommandSync( + `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }` + ) + return stdout.trim() === 'alive' +} + +function runJsonCommand(command) { + const { stdout, stderr, code, error } = runCommandSync(command) + if (error) { + throw new Error(`PowerShell spawn failed: ${error.message}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + // No matches: ConvertTo-Json of an empty array can emit nothing. + return { processes: [] } + } + try { + return JSON.parse(trimmed) + } catch (parseError) { + throw new Error( + `daemon-processes query returned non-JSON (exit ${code}): ${parseError.message}\n` + + `stdout:\n${trimmed}\nstderr:\n${stderr}` + ) + } +} + +function normalizeArray(raw) { + if (!raw) { + return [] + } + return Array.isArray(raw) ? raw : [raw] +} + +function parseUserDataArg(argv) { + const idx = argv.indexOf('--user-data') + return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() +} + +function runStandalone(argv) { + assertWin32('daemon-processes standalone') + const userDataDir = parseUserDataArg(argv) + console.log(`[daemon-processes] userData: ${userDataDir}`) + + const pidFiles = readDaemonPidFiles(userDataDir) + console.log(`[daemon-processes] PID files (${pidFiles.length}):`) + console.log(JSON.stringify(pidFiles, null, 2)) + + const scopeIdx = argv.indexOf('--scope') + const scope = scopeIdx >= 0 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' + const processes = findDaemonProcesses(scope) + console.log( + `[daemon-processes] live daemon processes${scope ? ` scoped to "${scope}"` : ''} (${processes.length}):` + ) + console.log(JSON.stringify(processes, null, 2)) + + for (const rec of pidFiles) { + if (typeof rec.pid === 'number') { + console.log(`[daemon-processes] pid ${rec.pid} alive: ${isPidAlive(rec.pid)}`) + } + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + try { + runStandalone(process.argv.slice(2)) + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} diff --git a/tools/win-update-e2e/installer-steps.mjs b/tools/win-update-e2e/installer-steps.mjs new file mode 100644 index 00000000000..18ba4ed09f0 --- /dev/null +++ b/tools/win-update-e2e/installer-steps.mjs @@ -0,0 +1,202 @@ +// Silent NSIS install / update / uninstall and installed-app discovery. +// +// Orca ships a per-user oneClick NSIS installer (electron-builder defaults: +// oneClick=true, perMachine=false) named orca-windows-setup.exe. One-click +// silent mode is ` /S`; the app installs under +// %LOCALAPPDATA%\Programs\ and the exe is Orca.exe. The install dir casing +// is not guaranteed (observed lowercase "orca" on a dev box), so the exe is +// located by search, never by a hard-coded path. + +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { assertWin32 } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' + +const PRODUCT_NAME = 'Orca' +const EXE_NAME = 'Orca.exe' + +/** Programs root that per-user oneClick NSIS installs into. */ +function programsRoot() { + const localAppData = + process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Local') + return path.join(localAppData, 'Programs') +} + +/** + * Resolve a base/update installer to a local .exe path, downloading from a + * GitHub release tag when requested. Keeps gh usage to a single `release + * download` call (AGENTS.md rate-limit guidance). + */ +export function resolveInstaller({ localPath, releaseTag, assetPattern }) { + if (localPath) { + if (!existsSync(localPath)) { + throw new Error(`Installer not found: ${localPath}`) + } + return path.resolve(localPath) + } + if (!releaseTag) { + throw new Error('resolveInstaller: neither localPath nor releaseTag provided') + } + const outDir = mkdtempSync(path.join(tmpdir(), 'orca-e2e-installer-')) + const result = spawnSync( + 'gh', + ['release', 'download', releaseTag, '--pattern', assetPattern, '--dir', outDir], + { encoding: 'utf8' } + ) + if (result.status !== 0) { + throw new Error( + `gh release download ${releaseTag} failed (exit ${result.status}): ${result.stderr || result.stdout}` + ) + } + const found = findSetupExe(outDir) + if (!found) { + throw new Error(`No installer matching "${assetPattern}" in downloaded release ${releaseTag}`) + } + return found +} + +function findSetupExe(dir) { + const { stdout } = runCommandSync( + `Get-ChildItem -Path '${dir}' -Filter '*.exe' -Recurse -ErrorAction SilentlyContinue | ` + + `Select-Object -First 1 -ExpandProperty FullName` + ) + const line = stdout.trim().split('\n')[0]?.trim() + return line && existsSync(line) ? line : null +} + +/** + * Run an NSIS installer in one-click silent mode and wait for the installed exe + * to appear. Returns { exePath, version }. The installer process returns before + * copying finishes, so completion is confirmed by polling for the exe. + * + * When `installDir` is set (isolated-install mode), `/D=` overrides the + * install location. `/D` is special in NSIS: it must be the LAST argument and + * cannot be quoted, so the path must be spaces-free (validated upstream) and is + * passed as a single unquoted argv entry. + */ +export function silentInstall(setupExe, { timeoutMs = 180_000, installDir = null } = {}) { + assertWin32('silentInstall') + if (!existsSync(setupExe)) { + throw new Error(`Installer not found: ${setupExe}`) + } + // /S is the NSIS silent switch; the electron-builder oneClick installer needs + // no other flags for a per-user install. /D, when present, MUST be last. + const args = ['/S'] + if (installDir) { + args.push(`/D=${installDir}`) + } + const proc = spawnSync(setupExe, args, { encoding: 'utf8' }) + if (proc.error) { + throw new Error(`Failed to launch installer ${setupExe}: ${proc.error.message}`) + } + + // On update runs the old Orca.exe already exists, so wait for the exe whose + // version matches this installer — not just any exe the installer hasn't yet + // overwritten — to avoid reading the pre-update binary mid-copy. + const targetVersion = getExeVersion(setupExe) + const exePath = waitForInstalledExe(timeoutMs, installDir, targetVersion) + if (!exePath) { + const where = installDir ?? programsRoot() + throw new Error(`Installed ${EXE_NAME} did not appear under ${where} within ${timeoutMs}ms`) + } + return { exePath, version: getExeVersion(exePath) } +} + +function waitForInstalledExe(timeoutMs, installDir = null, expectedVersion = null) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const exe = locateInstalledExe(installDir) + if (exe && (!expectedVersion || getExeVersion(exe) === expectedVersion)) { + return exe + } + sleepSync(1000) + } + return null +} + +/** + * Locate the installed Orca.exe. In isolated mode (`installDir` set), the exe is + * at a known fixed path (\Orca.exe). Otherwise it is discovered + * under %LOCALAPPDATA%\Programs (case-tolerant — casing is not guaranteed). + */ +export function locateInstalledExe(installDir = null) { + if (installDir) { + const exe = path.join(installDir, EXE_NAME) + return existsSync(exe) ? exe : null + } + const root = programsRoot() + if (!existsSync(root)) { + return null + } + const { stdout } = runCommandSync( + `Get-ChildItem -Path '${root}' -Directory -ErrorAction SilentlyContinue | ` + + `ForEach-Object { Join-Path $_.FullName '${EXE_NAME}' } | ` + + `Where-Object { Test-Path $_ } | Select-Object -First 1` + ) + const line = stdout.trim().split('\n')[0]?.trim() + return line && existsSync(line) ? line : null +} + +/** Read the ProductVersion string from an exe's version resource. */ +export function getExeVersion(exePath) { + const { stdout } = runCommandSync(`(Get-Item '${exePath}').VersionInfo.ProductVersion`) + return stdout.trim() || null +} + +/** + * Silently uninstall the test install at an EXPLICIT directory via its + * NSIS-generated uninstaller. Best-effort: returns false if no uninstaller is + * found rather than throwing, so teardown never masks the real assertion result. + * + * SAFETY: `installDir` is REQUIRED and must be the exact directory the harness + * installed into this run — there is deliberately no scan-and-discover fallback, + * because a `null` default once made this function locate and uninstall the + * developer's REAL Orca. It additionally refuses to run against the default + * per-user install location unless `allowDefaultLocation` is explicitly set (only + * the owns-the-install non-isolated teardown may do so). + */ +export function silentUninstall(installDir, { allowDefaultLocation = false } = {}) { + assertWin32('silentUninstall') + if (typeof installDir !== 'string' || installDir.trim() === '') { + throw new Error('silentUninstall requires an explicit install directory (no scan fallback)') + } + const resolved = path.resolve(installDir) + if (!allowDefaultLocation && pathsEqual(resolved, path.join(programsRoot(), PRODUCT_NAME))) { + throw new Error( + `Refusing to uninstall the default install location "${resolved}" — this is where a ` + + `developer's REAL Orca lives. Isolated mode must target a separate --install-dir.` + ) + } + const exe = path.join(resolved, EXE_NAME) + if (!existsSync(exe)) { + return false + } + const exeDir = resolved + const uninstaller = path.join(exeDir, `Uninstall ${PRODUCT_NAME}.exe`) + if (!existsSync(uninstaller)) { + return false + } + // NSIS uninstallers must be run from a copy (they relocate themselves); _?= + // forces synchronous, in-place uninstall so we can assert completion. + spawnSync(uninstaller, ['/S', `_?=${exeDir}`], { encoding: 'utf8' }) + sleepSync(2000) + return !existsSync(exe) +} + +/** Case-insensitive path equality after trailing-separator normalization. */ +function pathsEqual(a, b) { + const norm = (p) => + path + .resolve(p) + .replace(/[\\/]+$/, '') + .toLowerCase() + return norm(a) === norm(b) +} + +function sleepSync(ms) { + // Blocking sleep via Atomics keeps install polling simple and synchronous. + const sab = new Int32Array(new SharedArrayBuffer(4)) + Atomics.wait(sab, 0, 0, ms) +} diff --git a/tools/win-update-e2e/interactivity-probes.mjs b/tools/win-update-e2e/interactivity-probes.mjs new file mode 100644 index 00000000000..fd58a35465b --- /dev/null +++ b/tools/win-update-e2e/interactivity-probes.mjs @@ -0,0 +1,99 @@ +// Filesystem-sentinel interactivity probes for a packaged terminal session. +// +// These prove a session is interactive without reading the (WebGL) xterm +// buffer: a typed command writes a marker FILE, and the harness checks the +// file. That verifies keystrokes reached the shell AND the shell executed them. + +import { existsSync, statSync, readFileSync, rmSync } from 'node:fs' +import path from 'node:path' +import { sendCtrlC, runShellCommand } from './app-driver.mjs' + +const POLL_MS = 500 + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitForFile(filePath, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (existsSync(filePath)) { + return true + } + await delay(POLL_MS) + } + return false +} + +/** + * Type a command that writes a unique token to a sentinel file, then confirm + * the file appears with that token. Proves typed input reaches and runs in the + * active terminal. Returns true on success. + */ +export async function probeEcho(page, runDir, label = 'echo') { + const token = `${label.toUpperCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const file = path.join(runDir, `${label}-${token}.txt`) + await runShellCommand(page, `Set-Content -LiteralPath '${file}' -Value '${token}'`) + const appeared = await waitForFile(file, 15_000) + if (!appeared) { + return false + } + return readFileSync(file, 'utf8').includes(token) +} + +/** Latest mtime (ms) of a file, or 0 if absent. */ +export function fileMtimeMs(filePath) { + try { + return statSync(filePath).mtimeMs + } catch { + return 0 + } +} + +/** + * Confirm a heartbeat file keeps advancing (session is live and streaming). + * Samples twice Poll apart; returns true if the second sample is newer. + */ +export async function probeHeartbeatAdvancing(heartbeatFile) { + const first = fileMtimeMs(heartbeatFile) + await delay(1500) + const second = fileMtimeMs(heartbeatFile) + return second > first && second > 0 +} + +/** + * Ctrl+C on a foreground marker loop: after interrupt the heartbeat must STOP + * advancing and the shell must return to a prompt (a follow-up sentinel command + * runs). Both conditions are required — output stopping alone could be a hang. + */ +export async function probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) { + await sendCtrlC(page) + await delay(1500) + const afterCtrlC = fileMtimeMs(heartbeatFile) + await delay(1500) + const stillLater = fileMtimeMs(heartbeatFile) + const heartbeatStopped = stillLater === afterCtrlC + const promptReturned = await probeEcho(page, runDir, 'post-interrupt') + return heartbeatStopped && promptReturned +} + +/** + * cold-restore Ctrl+C: start a fresh foreground sleep loop in the active (new) + * terminal, interrupt it, then confirm the prompt returns via a sentinel. + */ +export async function probeCtrlCOnFreshLoop(page, runDir) { + const heartbeatFile = path.join(runDir, `fresh-loop-${Date.now()}.txt`) + if (existsSync(heartbeatFile)) { + rmSync(heartbeatFile) + } + await runShellCommand( + page, + `while($true){ [System.IO.File]::WriteAllText('${heartbeatFile}',(Get-Date).ToString('o')); Start-Sleep -Milliseconds 500 }` + ) + // Let the loop spin up and prove it is actually running before interrupting. + const running = await probeHeartbeatAdvancing(heartbeatFile) + if (!running) { + return false + } + return probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) +} diff --git a/tools/win-update-e2e/onboarding-profile.mjs b/tools/win-update-e2e/onboarding-profile.mjs new file mode 100644 index 00000000000..dd1d4771d3a --- /dev/null +++ b/tools/win-update-e2e/onboarding-profile.mjs @@ -0,0 +1,77 @@ +// Persistence seed for a fresh packaged Orca profile: dismisses onboarding and +// registers a throwaway git repo as a project so the harness can open a +// workspace + terminal without the native "Add Project" folder dialog (which +// Playwright cannot drive). +// +// A first-run profile renders a fullscreen onboarding overlay (`fixed inset-0 +// z-[100]`) that intercepts every pointer event; the renderer shows it only +// while `onboarding.closedAt === null` (src/renderer/src/components/onboarding/ +// should-show-onboarding.ts), so writing this object to `/ +// orca-data.json` BEFORE launch dismisses it. The app derives the Projects list +// from the persisted `repos` array (src/main/persistence.ts), so a single repo +// entry pointing at a real git checkout makes the project selectable. +// +// IMPORTANT: seed only BEFORE the first launch. The app rewrites orca-data.json +// on quit; overwriting it before the post-update relaunch would destroy the +// persisted session that the cold-restore/survival assertions depend on. +// +// Onboarding flow-version / final-step mirror src/shared/constants +// (ONBOARDING_FLOW_VERSION=4, ONBOARDING_FINAL_STEP=5); refresh if the app bumps +// the flow version, or a stale version re-arms onboarding. + +import { writeFileSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +const ONBOARDING_FLOW_VERSION = 4 +const ONBOARDING_FINAL_STEP = 5 + +/** + * Create a throwaway git repo under `dir` and return a persisted `Repo` entry + * for it. A real checkout (init + one commit) is required — Orca treats a + * project as a git repository. + */ +export function createSeededRepo(dir) { + mkdirSync(dir, { recursive: true }) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-b', 'main') + git('config', 'user.email', 'win-update-e2e@orca.test') + git('config', 'user.name', 'win-update-e2e') + writeFileSync(path.join(dir, 'README.md'), '# win-update-e2e fixture repo\n') + git('add', '-A') + git('commit', '-m', 'seed') + return { + id: '00000000-0000-4000-8000-00000000e2e0', + path: dir, + displayName: 'e2e-fixture', + badgeColor: '#888888', + addedAt: 1 + } +} + +/** The persisted profile object: onboarding dismissed + telemetry opted in + + * an optional seeded repo. */ +export function buildFreshProfile({ repo = null } = {}) { + return { + settings: { + telemetry: { + optedIn: true, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: ONBOARDING_FINAL_STEP + }, + repos: repo ? [repo] : [] + } +} + +/** Write a fresh profile into a userData dir before the FIRST launch only. */ +export function seedFreshProfile(userDataDir, profile) { + mkdirSync(userDataDir, { recursive: true }) + writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`) +} diff --git a/tools/win-update-e2e/platform-guard.mjs b/tools/win-update-e2e/platform-guard.mjs new file mode 100644 index 00000000000..faf9fc45133 --- /dev/null +++ b/tools/win-update-e2e/platform-guard.mjs @@ -0,0 +1,38 @@ +// Windows-only guards for the packaged-update E2E harness. +// +// This tool drives real NSIS installers, named-pipe daemons, and Win32 window +// enumeration, none of which exist off Windows. Every entry point calls +// assertWin32() so a macOS/Linux invocation fails loudly with a useful message +// instead of throwing an opaque "powershell: command not found" later. + +import { runCommandSync } from './powershell-runner.mjs' + +/** Throw a clear error unless we are on win32. */ +export function assertWin32(context = 'win-update-e2e') { + if (process.platform !== 'win32') { + throw new Error( + `${context} is Windows-only (it drives NSIS installers, named-pipe ` + + `daemons, and Win32 window enumeration). Detected platform ` + + `"${process.platform}". Run it on a Windows machine or CI runner.` + ) + } +} + +/** + * True when this process is running elevated. Non-elevated is the expected, + * supported mode (per-user oneClick NSIS needs no elevation); this is only used + * to print an informational warning during preflight. + */ +export function isElevated() { + if (process.platform !== 'win32') { + return false + } + // Use the WindowsPrincipal role check via PowerShell rather than `whoami`, + // which under a Git Bash / MSYS PATH can resolve to a Unix whoami that + // rejects the /groups flag. + const { stdout } = runCommandSync( + `[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()` + + `).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)` + ) + return stdout.trim().toLowerCase() === 'true' +} diff --git a/tools/win-update-e2e/powershell-runner.mjs b/tools/win-update-e2e/powershell-runner.mjs new file mode 100644 index 00000000000..68561b26120 --- /dev/null +++ b/tools/win-update-e2e/powershell-runner.mjs @@ -0,0 +1,87 @@ +// Thin wrapper around Windows PowerShell 5.1 for the harness's Win32 probes. +// +// Everything that inspects windows or processes goes through Windows PowerShell +// (powershell.exe) rather than pwsh, because 5.1 is guaranteed present on every +// Windows box and the .ps1 probes are written for its quirks (notably the +// single-item .Count pitfall — see window-enum.ps1 / daemon-processes.mjs). + +import { spawn, spawnSync } from 'node:child_process' + +const POWERSHELL = 'powershell.exe' +const BASE_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'] +// Cap sync probes so a wedged PowerShell call can't block the harness until the +// whole CI job times out. Callers can override via opts.timeout. +const DEFAULT_SYNC_TIMEOUT_MS = 60_000 + +/** + * Run a .ps1 file synchronously and return { code, stdout, stderr }. + * scriptArgs is an array of string arguments passed after -File. + */ +export function runScriptFileSync(scriptPath, scriptArgs = [], opts = {}) { + const result = spawnSync(POWERSHELL, [...BASE_ARGS, '-File', scriptPath, ...scriptArgs], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: DEFAULT_SYNC_TIMEOUT_MS, + ...opts + }) + return { + code: result.status ?? (result.error ? -1 : 0), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? null + } +} + +/** + * Run a .ps1 file whose stdout is a single JSON document and return the parsed + * value. Throws with the raw stderr/stdout attached when parsing fails, so a + * malformed probe surfaces its actual PowerShell error instead of a bare + * SyntaxError. + */ +export function runScriptFileJson(scriptPath, scriptArgs = [], opts = {}) { + const { code, stdout, stderr, error } = runScriptFileSync(scriptPath, scriptArgs, opts) + if (error) { + throw new Error(`Failed to spawn PowerShell for ${scriptPath}: ${error.message}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + throw new Error( + `PowerShell script ${scriptPath} produced no stdout (exit ${code}). stderr:\n${stderr}` + ) + } + try { + return JSON.parse(trimmed) + } catch (parseError) { + throw new Error( + `PowerShell script ${scriptPath} did not emit valid JSON (exit ${code}): ` + + `${parseError.message}\n--- stdout ---\n${trimmed}\n--- stderr ---\n${stderr}` + ) + } +} + +/** + * Spawn a .ps1 file as a long-running background child. Returns the ChildProcess + * so the caller can track/stop it. Used for the window watch loop. + */ +export function spawnScriptFile(scriptPath, scriptArgs = [], opts = {}) { + return spawn(POWERSHELL, [...BASE_ARGS, '-File', scriptPath, ...scriptArgs], { + stdio: ['ignore', 'pipe', 'pipe'], + ...opts + }) +} + +/** Run an inline command string synchronously and return { code, stdout, stderr }. */ +export function runCommandSync(command, opts = {}) { + const result = spawnSync(POWERSHELL, [...BASE_ARGS, '-Command', command], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: DEFAULT_SYNC_TIMEOUT_MS, + ...opts + }) + return { + code: result.status ?? (result.error ? -1 : 0), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? null + } +} diff --git a/tools/win-update-e2e/preflight.mjs b/tools/win-update-e2e/preflight.mjs new file mode 100644 index 00000000000..b5218ed0130 --- /dev/null +++ b/tools/win-update-e2e/preflight.mjs @@ -0,0 +1,161 @@ +// Preflight safety checks and the baseline window snapshot. +// +// The harness installs, updates, and uninstalls a real app and kills processes +// it created. To avoid ever touching a user's live Orca, it REFUSES to run when +// a pre-existing Orca app process (not a daemon) is already running that it did +// not start. Existing installs and detached daemons are warned about, not +// treated as fatal (the update path is what exercises them). + +import path from 'node:path' +import { assertWin32, isElevated } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' +import { captureBaseline } from './window-watch.mjs' +import { locateInstalledExe } from './installer-steps.mjs' +import { findDaemonProcesses } from './daemon-processes.mjs' + +/** + * Find running Orca APP processes (main window process), excluding daemons. + * The daemon runs as Orca.exe too but always carries the daemon-entry.js marker + * on its command line, so excluding that marker isolates the actual app. The + * ExecutablePath lets isolated mode decide whether a running app is under the + * test dir (fatal) or is the developer's real Orca elsewhere (informational). + */ +export function findAppProcesses() { + const command = [ + `$procs = @(Get-CimInstance Win32_Process -Filter "Name = 'Orca.exe'" -ErrorAction SilentlyContinue |`, + ` Where-Object { -not ($_.CommandLine -match 'daemon-entry\\.js') })`, + `$out = @($procs | ForEach-Object {`, + ` [pscustomobject]@{ pid = $_.ProcessId; path = $_.ExecutablePath; commandLine = $_.CommandLine } })`, + `ConvertTo-Json -InputObject @{ processes = $out } -Depth 4 -Compress` + ].join('\n') + // Fail closed: this guard protects the user's real processes, so a failed + // query must abort the run rather than look like "no Orca is running". + const { stdout, stderr, code, error } = runCommandSync(command) + if (error) { + throw new Error(`Failed to query Orca app processes: ${error.message}`) + } + if (code !== 0) { + throw new Error(`Failed to query Orca app processes (exit ${code}): ${stderr || stdout}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + return [] + } + try { + const parsed = JSON.parse(trimmed) + const arr = parsed.processes + return Array.isArray(arr) ? arr : arr ? [arr] : [] + } catch (parseError) { + throw new Error( + `Orca app process query returned invalid JSON: ${parseError.message}\n` + + `stdout:\n${trimmed}\nstderr:\n${stderr}` + ) + } +} + +/** True if `childPath` is equal to or inside `parentDir` (case-insensitive). */ +function isPathUnder(childPath, parentDir) { + const child = path + .resolve(childPath) + .replace(/[\\/]+$/, '') + .toLowerCase() + const parent = path + .resolve(parentDir) + .replace(/[\\/]+$/, '') + .toLowerCase() + return child === parent || child.startsWith(`${parent}\\`) +} + +/** + * Run preflight. Returns { baseline, warnings, existingInstall }. Throws if a + * pre-existing Orca app is running (never kill a user's process) or if an Orca + * install already exists and allowExistingInstall was not passed (the run would + * overwrite a developer's real build). baselinePath receives the snapshot of + * currently-visible top-level windows. + * + * In isolated mode (`installDir` set) both refusals become target-scoped: only a + * running app whose exe is UNDER installDir is fatal, and only an install already + * in installDir triggers the existing-install refusal. A real Orca running or + * installed elsewhere is untouched by isolated mode and is merely noted. + */ +export function preflight({ baselinePath, allowExistingInstall = false, installDir = null }) { + assertWin32('preflight') + const warnings = [] + const isolated = Boolean(installDir) + + if (isElevated()) { + warnings.push( + 'Running elevated. A per-user oneClick install does not need elevation; ' + + 'an elevated run can install to an unexpected profile.' + ) + } + + const appProcesses = findAppProcesses() + if (isolated) { + const inTarget = appProcesses.filter((p) => p.path && isPathUnder(p.path, installDir)) + if (inTarget.length > 0) { + const listing = inTarget.map((p) => ` pid ${p.pid}: ${p.path}`).join('\n') + throw new Error( + `Refusing to run: ${inTarget.length} Orca app process(es) are running from the ` + + `isolated target dir ${installDir} that this harness did not start. Close them ` + + `first (this harness never kills pre-existing user processes):\n${listing}` + ) + } + const elsewhere = appProcesses.filter((p) => !(p.path && isPathUnder(p.path, installDir))) + if (elsewhere.length > 0) { + warnings.push( + `${elsewhere.length} Orca app process(es) are running from outside the isolated ` + + `target dir (pids: ${elsewhere.map((p) => p.pid).join(', ')}). Isolated mode never ` + + `touches them; proceeding.` + ) + } + } else if (appProcesses.length > 0) { + const listing = appProcesses.map((p) => ` pid ${p.pid}: ${p.commandLine}`).join('\n') + throw new Error( + `Refusing to run: ${appProcesses.length} Orca app process(es) are already ` + + `running that this harness did not start. Close them first (this harness ` + + `never kills pre-existing user processes):\n${listing}` + ) + } + + // Scope the existing-install check to the target dir in isolated mode; an + // install at the default location is left untouched and does not count. + const existingInstall = isolated ? locateInstalledExe(installDir) : locateInstalledExe() + if (existingInstall && !allowExistingInstall) { + throw new Error( + isolated + ? `Refusing to run: an install already exists in the isolated target dir ` + + `${existingInstall}. Pass --allow-existing-install to overwrite it (isolated mode ` + + `never touches the real install elsewhere), or point --install-dir at an empty dir.` + : `Refusing to run: an Orca install already exists at ${existingInstall}. ` + + `This run would silently OVERWRITE it with the --from/--to versions and ` + + `leave the --to version installed — destroying a real Orca install on a ` + + `developer machine. Pass --allow-existing-install to proceed anyway ` + + `(your prior build will NOT be restored), or uninstall Orca first. Clean ` + + `machines (CI/VM) never hit this.` + ) + } + if (existingInstall && !isolated) { + warnings.push( + `--allow-existing-install set: the existing install at ${existingInstall} will be ` + + `overwritten and the --to version left installed; teardown will NOT uninstall it.` + ) + } else if (existingInstall) { + warnings.push( + `A prior harness install exists in the target dir ${existingInstall}; it will be ` + + `overwritten and cleaned up at teardown (isolated mode owns the test dir).` + ) + } + + const existingDaemons = findDaemonProcesses() + if (existingDaemons.length > 0) { + warnings.push( + `${existingDaemons.length} pre-existing daemon process(es) found on this machine ` + + `(pids: ${existingDaemons.map((d) => d.pid).join(', ')}). The run uses an isolated ` + + `userData dir, so its daemon is tracked by scope and will not collide.` + ) + } + + const baseline = captureBaseline(baselinePath) + return { baseline, warnings, existingInstall } +} diff --git a/tools/win-update-e2e/registry-shortcut-backup.mjs b/tools/win-update-e2e/registry-shortcut-backup.mjs new file mode 100644 index 00000000000..59f66e914bc --- /dev/null +++ b/tools/win-update-e2e/registry-shortcut-backup.mjs @@ -0,0 +1,312 @@ +// Snapshot and restore the per-user install state that an NSIS install hijacks. +// +// electron-builder's NSIS honors the /D install-dir override for FILE layout, so +// isolated mode installs into a separate directory. But regardless of /D, the +// installer writes InstallLocation + the uninstall entry to the SAME per-user +// HKCU keys as the real install, and rewrites the Start Menu / Desktop shortcuts +// (node_modules/app-builder-lib/templates/nsis/include/installer.nsh). Left +// hijacked, a developer's next REAL update would target the test directory. +// +// This module snapshots those shared keys + shortcuts before an isolated run and +// restores them at teardown, always. All registry access is via reg.exe (atomic +// export/import/delete); discovery is read-only PowerShell. + +import { existsSync, mkdirSync, copyFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { runCommandSync } from './powershell-runner.mjs' + +const UNINSTALL_ROOT = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' +const APP_ROOT = 'HKCU\\Software' + +/** + * Read-only discovery of the machine's current Orca install registry state. + * The uninstall entry is found by DisplayName (electron-builder writes the app + * GUID as the key name, not a fixed string); the app key that carries + * InstallLocation is found under HKCU\Software by its ShortcutName/InstallLocation. + * Returns concrete reg.exe key paths + the current InstallLocation value (all + * null when no install exists). + */ +export function discoverInstallRegistryState() { + const ps = [ + `$un = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall'`, + `$uninstallName = $null; $displayName = $null; $displayVersion = $null`, + `foreach ($k in @(Get-ChildItem $un -ErrorAction SilentlyContinue)) {`, + ` $dn = $k.GetValue('DisplayName'); $il = $k.GetValue('InstallLocation')`, + ` if ($dn -eq 'Orca' -or ($il -and $il -match '\\\\Programs\\\\orca')) {`, + ` $uninstallName = $k.PSChildName; $displayName = $dn; $displayVersion = $k.GetValue('DisplayVersion')`, + ` }`, + `}`, + `$appName = $null; $installLocation = $null`, + `foreach ($k in @(Get-ChildItem 'HKCU:\\Software' -ErrorAction SilentlyContinue)) {`, + ` $il = $k.GetValue('InstallLocation'); $sn = $k.GetValue('ShortcutName')`, + ` if ($il -and ($sn -eq 'Orca' -or $il -match 'orca')) {`, + ` $appName = $k.PSChildName; $installLocation = $il`, + ` }`, + `}`, + `ConvertTo-Json -Compress -InputObject @{ uninstallName = $uninstallName; appName = $appName; installLocation = $installLocation; displayName = $displayName; displayVersion = $displayVersion }` + ].join('\n') + const { stdout } = runCommandSync(ps) + let parsed = {} + try { + parsed = JSON.parse(stdout.trim() || '{}') + } catch { + parsed = {} + } + return { + uninstallKey: parsed.uninstallName ? `${UNINSTALL_ROOT}\\${parsed.uninstallName}` : null, + appKey: parsed.appName ? `${APP_ROOT}\\${parsed.appName}` : null, + installLocation: parsed.installLocation || null, + displayName: parsed.displayName || null, + displayVersion: parsed.displayVersion || null + } +} + +/** The two directories NSIS writes Orca shortcuts into: Start Menu + Desktop. */ +export function shortcutDirs() { + const appData = + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming') + const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs') + return [startMenu, resolveDesktopDir()] +} + +function resolveDesktopDir() { + // Desktop can be redirected (OneDrive), so resolve via the shell folder API + // rather than assuming %USERPROFILE%\Desktop. + const { stdout } = runCommandSync(`[Environment]::GetFolderPath('Desktop')`) + const line = stdout.trim() + if (line && existsSync(line)) { + return line + } + return path.join(process.env.USERPROFILE ?? '', 'Desktop') +} + +/** Read-only: list Orca *.lnk shortcuts under the given dirs (recursive). */ +export function discoverOrcaShortcuts(dirs = shortcutDirs()) { + const found = [] + for (const dir of dirs) { + collectOrcaLnks(dir, found) + } + return found +} + +function collectOrcaLnks(dir, out) { + if (!existsSync(dir)) { + return + } + let entries = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + collectOrcaLnks(full, out) + } else if (entry.isFile() && /\.lnk$/i.test(entry.name) && /orca/i.test(entry.name)) { + out.push(full) + } + } +} + +/** + * Snapshot the shared install registry keys + Orca shortcuts before an isolated + * install writes over them. `reg export`s each existing key and copies each + * existing shortcut into runDir, recording a manifest of what pre-existed (and + * the pre-run InstallLocation) so restore knows what to put back vs. delete. + */ +export function backupInstallState(runDir) { + const backupDir = path.join(runDir, 'install-state-backup') + mkdirSync(backupDir, { recursive: true }) + const state = discoverInstallRegistryState() + const dirs = shortcutDirs() + const manifest = { + backupDir, + shortcutDirs: dirs, + installLocation: state.installLocation, + uninstall: null, + app: null, + shortcuts: [] + } + if (state.uninstallKey) { + const regFile = path.join(backupDir, 'uninstall.reg') + if (regExport(state.uninstallKey, regFile)) { + manifest.uninstall = { path: state.uninstallKey, regFile } + } + } + if (state.appKey) { + const regFile = path.join(backupDir, 'app.reg') + if (regExport(state.appKey, regFile)) { + manifest.app = { path: state.appKey, regFile } + } + } + let i = 0 + for (const lnk of discoverOrcaShortcuts(dirs)) { + const backup = path.join(backupDir, `shortcut-${i}-${path.basename(lnk)}`) + try { + copyFileSync(lnk, backup) + manifest.shortcuts.push({ path: lnk, backup }) + } catch { + manifest.shortcuts.push({ path: lnk, backup: null }) + } + i += 1 + } + writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) + return manifest +} + +/** + * Restore the shared install state captured by backupInstallState. Keys that + * pre-existed are `reg import`ed back to their original values; keys that did NOT + * pre-exist but exist now (created by the test install) are `reg delete`d. + * Pre-existing shortcuts are copied back; test-created Orca shortcuts are removed. + * Finally re-reads InstallLocation and, on mismatch, prints a LOUD block with the + * exact manual `reg import` command to recover. + */ +export function restoreInstallState(manifest) { + const result = { + imported: [], + deleted: [], + failures: [], + shortcutsRestored: [], + shortcutsDeleted: [], + verified: false + } + const current = discoverInstallRegistryState() + + if (manifest.uninstall) { + if (regImport(manifest.uninstall.regFile)) { + result.imported.push(manifest.uninstall.path) + } else { + result.failures.push(`Failed to import ${manifest.uninstall.regFile}`) + } + } else if (current.uninstallKey) { + if (regDelete(current.uninstallKey)) { + result.deleted.push(current.uninstallKey) + } else { + result.failures.push(`Failed to delete ${current.uninstallKey}`) + } + } + + if (manifest.app) { + if (regImport(manifest.app.regFile)) { + result.imported.push(manifest.app.path) + } else { + result.failures.push(`Failed to import ${manifest.app.regFile}`) + } + } else if (current.appKey) { + if (regDelete(current.appKey)) { + result.deleted.push(current.appKey) + } else { + result.failures.push(`Failed to delete ${current.appKey}`) + } + } + + restoreShortcuts(manifest, result) + + const after = discoverInstallRegistryState() + // A silently-failed import/delete can leave stale keys while InstallLocation + // still matches, so restore isn't "verified" unless every op also succeeded. + result.verified = + result.failures.length === 0 && + normLoc(manifest.installLocation) === normLoc(after.installLocation) + if (!result.verified) { + printMismatchWarning(manifest, manifest.installLocation, after.installLocation) + } + return result +} + +function restoreShortcuts(manifest, result) { + const preExisting = new Set(manifest.shortcuts.map((s) => s.path.toLowerCase())) + for (const s of manifest.shortcuts) { + if (s.backup && existsSync(s.backup)) { + try { + copyFileSync(s.backup, s.path) + result.shortcutsRestored.push(s.path) + } catch { + /* best effort — a missing dir means the shortcut target is gone anyway */ + } + } + } + for (const lnk of discoverOrcaShortcuts(manifest.shortcutDirs)) { + if (!preExisting.has(lnk.toLowerCase())) { + try { + rmSync(lnk, { force: true }) + result.shortcutsDeleted.push(lnk) + } catch { + /* best effort */ + } + } + } +} + +function printMismatchWarning(manifest, expected, actual) { + const bar = '!'.repeat(72) + const importCmds = [] + if (manifest.app) { + importCmds.push(`reg import "${manifest.app.regFile}"`) + } + if (manifest.uninstall) { + importCmds.push(`reg import "${manifest.uninstall.regFile}"`) + } + const recovery = + importCmds.length > 0 ? importCmds : ['(no backup was captured — no manual import available)'] + console.error(`\n${bar}`) + console.error('!! WIN-UPDATE-E2E: REGISTRY RESTORE VERIFICATION FAILED') + console.error(`!! Expected InstallLocation: ${expected ?? '(none / no pre-existing install)'}`) + console.error(`!! Actual InstallLocation: ${actual ?? '(none)'}`) + console.error('!! Your REAL Orca install pointer may be hijacked to the test directory.') + console.error('!! The next real Orca update could install into the test location.') + console.error('!! Recover manually by running these command(s) in an elevated-free shell:') + for (const cmd of recovery) { + console.error(`!! ${cmd}`) + } + console.error(`${bar}\n`) +} + +function normLoc(value) { + if (!value) { + return '' + } + return value.replace(/[\\/]+$/, '').toLowerCase() +} + +function regExport(keyPath, file) { + try { + execFileSync('reg', ['export', keyPath, file, '/y'], { stdio: 'ignore' }) + return existsSync(file) + } catch { + return false + } +} + +function regImport(file) { + if (!file || !existsSync(file)) { + return false + } + try { + execFileSync('reg', ['import', file], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function regDelete(keyPath) { + try { + execFileSync('reg', ['delete', keyPath, '/f'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Standalone read-only mode: print what an isolated run would snapshot. Touches +// nothing. `node registry-shortcut-backup.mjs` +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + console.log('[registry-shortcut-backup] discovered install registry state:') + console.log(JSON.stringify(discoverInstallRegistryState(), null, 2)) + console.log('[registry-shortcut-backup] Orca shortcuts that would be backed up:') + console.log(JSON.stringify(discoverOrcaShortcuts(), null, 2)) +} diff --git a/tools/win-update-e2e/run.mjs b/tools/win-update-e2e/run.mjs new file mode 100644 index 00000000000..7c8d8cde6c5 --- /dev/null +++ b/tools/win-update-e2e/run.mjs @@ -0,0 +1,628 @@ +// win-update-e2e — packaged NSIS update proof harness. +// +// Given two Orca Windows installers (version N and N+1), proves what happens to +// the terminal daemon and its sessions across a real silent update, with +// machine-checkable assertions. Windows-only. See README.md for usage and the +// design context in docs/windows-terminal-update-survival-plan.md (Phase 0). + +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { assertWin32 } from './platform-guard.mjs' +import { parseArgs } from './cli-args.mjs' +import { preflight } from './preflight.mjs' +import { resolveInstaller, silentInstall, silentUninstall } from './installer-steps.mjs' +import { backupInstallState, restoreInstallState } from './registry-shortcut-backup.mjs' +import { + launchInstalledApp, + ensureTerminal, + dismissOverlays, + createTerminalTab, + listTabIds, + startMarker, + readTerminalTextBestEffort, + closeApp, + captureFailureDiagnostics +} from './app-driver.mjs' +import { readDaemonPidFiles, findDaemonProcesses, isPidAlive } from './daemon-processes.mjs' +import { startWatch } from './window-watch.mjs' +import { + probeEcho, + probeHeartbeatAdvancing, + probeCtrlCInterruptsMarker, + probeCtrlCOnFreshLoop, + fileMtimeMs +} from './interactivity-probes.mjs' +import { buildAssertions, renderTable, allPassed } from './assertions.mjs' +import { createSeededRepo, buildFreshProfile } from './onboarding-profile.mjs' + +function log(step, msg) { + console.log(`[win-update-e2e] ${step}: ${msg}`) +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)) + if (opts.help) { + console.log(opts.usage) + return 0 + } + if (opts.errors?.length) { + console.error(`Argument errors:\n - ${opts.errors.join('\n - ')}\n${opts.usage}`) + return 2 + } + assertWin32('win-update-e2e') + + const installDir = opts.installDir ?? null + const isolated = Boolean(installDir) + + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const canary = `ORCA-E2E-CANARY-${runId}` + const runDir = mkdtempSync(path.join(tmpdir(), `orca-win-update-e2e-${runId}-`)) + const userDataDir = path.join(runDir, 'userData') + const baselinePath = path.join(runDir, 'baseline.json') + const watchOut = path.join(runDir, 'window-watch.jsonl') + const markerPidFile = path.join(runDir, 'marker.pid') + const heartbeatFile = path.join(runDir, 'heartbeat.txt') + const created = { markerPid: null, daemonPids: new Set() } + + log( + 'setup', + `runId=${runId} runDir=${runDir} profile=${opts.expect}${isolated ? ` installDir=${installDir}` : ''}` + ) + if (isolated && opts.keepInstall) { + log( + 'setup', + '--keep-install is ignored in isolated mode: the test install and its per-user ' + + 'registry hijack are always cleaned up so the real install stays safe.' + ) + } + + const { warnings, existingInstall } = preflight({ + baselinePath, + allowExistingInstall: opts.allowExistingInstall, + installDir + }) + const hadPreexistingInstall = Boolean(existingInstall) + for (const w of warnings) { + log('preflight-warning', w) + } + + // Isolated mode: snapshot the shared per-user registry keys + shortcuts BEFORE + // any install writes over them. Everything after this must run through the + // try/finally so the snapshot is always restored, even on failure. + let manifest = null + if (isolated) { + manifest = backupInstallState(runDir) + log('isolated', `backed up install registry/shortcut state -> ${manifest.backupDir}`) + } + + const runArgs = { + opts, + installDir, + canary, + runDir, + userDataDir, + baselinePath, + watchOut, + markerPidFile, + heartbeatFile, + created + } + + // Both paths ALWAYS tear down (close the app, kill the marker/watch, uninstall) + // and, in isolated mode, restore registry/shortcuts — whatever happens in the + // proof body. A driving failure captures diagnostics first, then surfaces as a + // FATAL. Teardown in a finally is what keeps a driving hang from pinning the + // Node process alive until the CI job timeout. + const ctx = { session: null } + const diagDir = process.env.ORCA_E2E_DIAG_DIR || path.join(runDir, 'diag') + let passed = false + try { + passed = await runProof(ctx, runArgs) + } catch (err) { + console.error(`[win-update-e2e] FATAL: ${err.stack || err.message}`) + if (ctx.session?.page) { + const diag = await captureFailureDiagnostics(ctx.session.page, diagDir, 'driving-failure') + log('diag', `captured -> ${diagDir} (store=${diag.info?.hasStore ?? 'n/a'})`) + if (diag.info?.bodyText) { + log('diag', `visible text: ${diag.info.bodyText.replace(/\s+/g, ' ').slice(0, 300)}`) + } + } + passed = false + } finally { + await (isolated + ? isolatedTeardown({ + app: ctx.session?.app, + created, + userDataDir, + installDir, + manifest, + runDir + }) + : teardown({ + app: ctx.session?.app, + created, + userDataDir, + keepInstall: opts.keepInstall, + hadPreexistingInstall, + installedExePath: ctx.installedExePath ?? null, + runDir + })) + } + return passed ? 0 : 1 +} + +/** + * Run the install → drive → update → relaunch → assert proof. `ctx.session` is + * assigned as soon as each app launches so a caller's finally can tear down a + * partially-created session on failure. Returns whether every assertion passed. + */ +async function runProof(ctx, args) { + const { + opts, + installDir, + canary, + runDir, + userDataDir, + baselinePath, + watchOut, + markerPidFile, + heartbeatFile, + created + } = args + + const fromInstaller = resolveInstaller({ + localPath: opts.from, + releaseTag: opts.fromRelease, + assetPattern: opts.assetPattern + }) + log('install-base', `installing ${fromInstaller}`) + const base = silentInstall(fromInstaller, { installDir }) + // Track the install now (not only after the update at L238) so a failure + // anywhere before the update still tears down the base install this harness + // created, instead of orphaning it and blocking the next run's preflight. + ctx.installedExePath = base.exePath + log('install-base', `installed ${base.exePath} (version ${base.version})`) + + // Seed a fresh profile (onboarding dismissed + one throwaway git repo as a + // project) ONLY before the first launch. The relaunch must use the app's own + // persisted state so the cold-restore/survival assertions are meaningful. + const seededRepo = createSeededRepo(path.join(runDir, 'fixture-repo')) + const seedProfile = buildFreshProfile({ repo: seededRepo }) + + // --- Base version: launch, create sessions, start marker, record daemon --- + let session = await launchInstalledApp({ exePath: base.exePath, userDataDir, seedProfile }) + ctx.session = session + // A fresh profile has no terminal yet, so create a workspace + terminal, then + // clear the post-creation overlays that would intercept typing. + await ensureTerminal(session.page, { allowCreate: true }) + await dismissOverlays(session.page) + const tabIds = await listTabIds(session.page) + log('sessions', `terminal ready; tab ids: ${tabIds.join(', ')}`) + + // Baseline typed-input on the IDLE shell, before the perpetual marker loop + // takes over the foreground (a command typed against a running loop can't + // execute, so this must precede startMarker to be meaningful). + const echoBeforeUpdate = await probeEcho(session.page, runDir, 'echo-pre') + log('sessions', `pre-update echo interactive: ${echoBeforeUpdate}`) + + await startMarker(session.page, { canary, pidFile: markerPidFile, heartbeatFile }) + const markerLive = await probeHeartbeatAdvancing(heartbeatFile) + created.markerPid = readIntFile(markerPidFile) + log('marker', `pid=${created.markerPid} heartbeatAdvancing=${markerLive}`) + + const preDaemon = resolveScopedDaemon(userDataDir) + preDaemon.pids.forEach((p) => created.daemonPids.add(p)) + log('daemon', `pre-update daemon pid=${preDaemon.pid} appVersion=${preDaemon.appVersion}`) + log('daemon', `pre-update daemon exe: ${preDaemon.exePath ?? '(unknown)'}`) + + const preScrollback = await readTerminalTextBestEffort(session.page) + + // Close app normally; the detached daemon must remain alive. + await closeApp(session.app) + const daemonAliveAfterClose = preDaemon.pid != null && isPidAlive(preDaemon.pid) + log('daemon', `alive after app close: ${daemonAliveAfterClose}`) + + // --- Start the console-window watch through the whole update + soak --- + const watchDuration = 120 + opts.soakSeconds + const watch = startWatch({ baselinePath, outPath: watchOut, durationSec: watchDuration }) + log('watch', `started (duration ${watchDuration}s) -> ${watchOut}`) + + // --- Update: install N+1 --- + const toInstaller = resolveInstaller({ + localPath: opts.to, + releaseTag: opts.toRelease, + assetPattern: opts.assetPattern + }) + log('update', `installing ${toInstaller}`) + const updated = silentInstall(toInstaller, { installDir }) + // Record the exact dir the harness installed into so non-isolated teardown + // uninstalls THAT and never scan-discovers the developer's real install. + ctx.installedExePath = updated.exePath + log('update', `installed ${updated.exePath} (version ${updated.version})`) + + // --- Relaunch and gather post-update evidence --- + session = await launchInstalledApp({ exePath: updated.exePath, userDataDir }) + ctx.session = session + // No create on relaunch: the session must be RESTORED, not freshly made. + await ensureTerminal(session.page, { allowCreate: false }) + await dismissOverlays(session.page) + + const evidence = await gatherEvidence({ + profile: opts.expect, + page: session.page, + runDir, + heartbeatFile, + preDaemon, + userDataDir, + preScrollback, + echoBeforeUpdate + }) + evidence.postDaemon?.pids?.forEach((p) => created.daemonPids.add(p)) + + // --- Soak, then stop the watch and evaluate --- + log('soak', `waiting ${opts.soakSeconds}s for delayed flashes`) + await delay(opts.soakSeconds * 1000) + const { events } = await watch.stop() + log('watch', `recorded ${events.length} new-window events`) + + const assertionCtx = { + profile: opts.expect, + canary, + watchEvents: events, + markerPid: created.markerPid, + daemonLog: readDaemonLog(userDataDir), + ...evidence + } + const assertions = buildAssertions(assertionCtx) + const passed = allPassed(assertions) + console.log(renderTable(assertions)) + log('result', passed ? 'PASS' : 'FAIL') + return passed +} + +async function gatherEvidence(args) { + const { profile, page, runDir, heartbeatFile, preDaemon, userDataDir, preScrollback } = args + const postDaemon = resolveScopedDaemon(userDataDir) + + if (profile === 'survival') { + // The decisive survival signals: did the SPECIFIC pre-update daemon process + // live through the update, and is the new app running that same relocated + // exe or a fresh in-dir fork? Log both regardless of the assertion outcome. + const preDaemonAliveAfter = preDaemon.pid != null && isPidAlive(preDaemon.pid) + log( + 'daemon', + `post-update daemon pid=${postDaemon.pid} exe: ${postDaemon.exePath ?? '(unknown)'}` + ) + log( + 'daemon', + `pre-update daemon pid=${preDaemon.pid} still alive after update: ${preDaemonAliveAfter}` + ) + dumpDaemonLog(userDataDir) + const heartbeatBefore = fileMtimeMs(heartbeatFile) + const heartbeatAdvancedAfterUpdate = await heartbeatAdvancedSince( + heartbeatFile, + heartbeatBefore + ) + // Sample marker survival BEFORE interrupting it: the pre-update shell must be + // measured while its heartbeat loop still runs, not after + // probeCtrlCInterruptsMarker deliberately breaks that loop. + const markerAliveAfter = isMarkerAlive(runDir) + log( + 'marker', + `pid=${readIntFile(path.join(runDir, 'marker.pid'))} aliveAfterUpdate=${markerAliveAfter}` + ) + // Interrupt the foreground loop first so the shell returns to a prompt; only + // then can a freshly-typed command execute. Typing while the infinite + // heartbeat loop owns the shell never runs, regardless of input health — so + // the echo probe is meaningful only after the interrupt. + const ctrlCInterrupted = await probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) + const echoObserved = await probeEcho(page, runDir, 'echo-post') + return { + preDaemonPid: preDaemon.pid, + preDaemonAliveAfter, + postDaemonPid: postDaemon.pid, + postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid), + postDaemon, + markerAliveAfter, + heartbeatAdvancedAfterUpdate, + echoObserved, + ctrlCInterrupted + } + } + + // cold-restore + const postScrollback = await readTerminalTextBestEffort(page) + const scrollbackRestored = scrollbackFidelity(preScrollback, postScrollback) + await createTerminalTab(page) + const echoObserved = await probeEcho(page, runDir, 'echo-fresh') + const ctrlCInterrupted = await probeCtrlCOnFreshLoop(page, runDir) + return { + preDaemonPid: preDaemon.pid, + preDaemonAliveAfter: preDaemon.pid != null && isPidAlive(preDaemon.pid), + postDaemonPid: postDaemon.pid, + postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid), + postDaemon, + scrollbackRestored, + echoObserved, + ctrlCInterrupted + } +} + +/** + * Resolve THIS run's daemon, scoped to its isolated userData dir so unrelated + * daemons on the machine are ignored. Prefers the pid file (authoritative, + * carries appVersion); cross-checks the live process scan. + */ +function resolveScopedDaemon(userDataDir) { + const pidFiles = readDaemonPidFiles(userDataDir) + const scan = findDaemonProcesses(userDataDir) + const pids = new Set() + for (const rec of pidFiles) { + if (typeof rec.pid === 'number') { + pids.add(rec.pid) + } + } + for (const proc of scan) { + if (typeof proc.pid === 'number') { + pids.add(proc.pid) + } + } + const primary = pidFiles.find((r) => typeof r.pid === 'number') + const pid = primary?.pid ?? scan[0]?.pid ?? null + const scanEntry = scan.find((p) => p.pid === pid) ?? scan[0] + return { + pid, + appVersion: primary?.appVersion ?? null, + startedAtMs: primary?.startedAtMs ?? null, + // Why: the daemon's exe path (first token of its command line) tells us + // whether it was forked from the relocated userData/daemon-host copy or the + // install-dir Orca.exe — the key survival signal. + exePath: daemonExePath(scanEntry?.commandLine), + pids: [...pids] + } +} + +/** Print the daemon's lifecycle log (Phase 0) so its startup/session events are + * visible in the CI log before teardown removes the userData dir. */ +function dumpDaemonLog(userDataDir) { + const logPath = path.join(userDataDir, 'logs', 'daemon.log') + try { + const lines = readFileSync(logPath, 'utf8').trim().split('\n') + log('daemon-log', `${logPath} (${lines.length} lines):`) + for (const line of lines.slice(-40)) { + console.log(` ${line}`) + } + } catch { + log('daemon-log', `${logPath} (unavailable)`) + } +} + +/** Extract the host exe path (first token) from a daemon command line. */ +function daemonExePath(commandLine) { + if (typeof commandLine !== 'string') { + return null + } + const trimmed = commandLine.trim() + if (trimmed.startsWith('"')) { + const end = trimmed.indexOf('"', 1) + return end > 0 ? trimmed.slice(1, end) : null + } + const space = trimmed.indexOf(' ') + return space > 0 ? trimmed.slice(0, space) : trimmed +} + +function isMarkerAlive(runDir) { + const pid = readIntFile(path.join(runDir, 'marker.pid')) + return pid != null && isPidAlive(pid) +} + +async function heartbeatAdvancedSince(heartbeatFile, sinceMs) { + await delay(1500) + return fileMtimeMs(heartbeatFile) > sinceMs +} + +function scrollbackFidelity(before, after) { + // WebGL renderer can leave both empty; report unknown (null) rather than a + // false failure. When text is available, check a stable prefix survived. + if (!before || !before.trim() || !after || !after.trim()) { + return null + } + const marker = before + .trim() + .split('\n') + .find((l) => l.trim().length > 3) + if (!marker) { + return null + } + return after.includes(marker.trim()) +} + +export function readDaemonLog(userDataDir) { + // The daemon log (when present) is JSONL: { src, ts, pid, event, ...details }. + // Only genuinely-bad records fail a run — matched by EVENT NAME, not by any + // string containing "error". The daemon logs benign 'uncaught-exception- + // suppressed' events with name:"Error" for native PTY errors it intentionally + // swallows (src/main/daemon/daemon-entry.ts); those must never fail, and + // 'client-hello-rejected' with reason expected-hello/protocol-mismatch is + // normal version-skew during an update. Non-JSON lines fall back to a raw + // FATAL match so an unstructured crash dump still counts. + const logPath = path.join(userDataDir, 'logs', 'daemon.log') + if (!existsSync(logPath)) { + return null + } + const errorLines = [] + let suppressedCount = 0 + for (const raw of readFileSync(logPath, 'utf8').split('\n')) { + const line = raw.trim() + if (!line) { + continue + } + let rec + try { + rec = JSON.parse(line) + } catch { + if (/\bFATAL\b/.test(line)) { + errorLines.push(line) + } + continue + } + if (rec.event === 'uncaught-exception-suppressed') { + suppressedCount += 1 + } else if ( + rec.event === 'uncaught-exception-fatal' || + (rec.event === 'client-hello-rejected' && rec.reason === 'invalid-token') + ) { + errorLines.push(line) + } + } + return { path: logPath, errorLines, suppressedCount } +} + +/** + * Isolated-mode teardown. The harness OWNS the isolated dir by construction, so + * it always uninstalls the test install, then ALWAYS restores the shared per-user + * registry keys + shortcuts the installer hijacked (the uninstaller clears the + * test install's copies; restore re-imports the real install's originals or + * deletes freshly-created keys). Finally removes the emptied install dir + runDir. + */ +async function isolatedTeardown({ app, created, userDataDir, installDir, manifest, runDir }) { + try { + await closeApp(app) + } catch { + /* already closed / never launched */ + } + killPid(created.markerPid) + for (const pid of resolveScopedDaemon(userDataDir).pids) { + killPid(pid) + } + for (const pid of created.daemonPids) { + killPid(pid) + } + + const uninstalled = silentUninstall(installDir) + log('isolated-teardown', `uninstalled test install: ${uninstalled}`) + + try { + const restore = restoreInstallState(manifest) + log( + 'isolated-teardown', + `registry restore verified=${restore.verified} imported=[${restore.imported.join(', ')}] ` + + `deleted=[${restore.deleted.join(', ')}] shortcutsRestored=${restore.shortcutsRestored.length} ` + + `shortcutsDeleted=${restore.shortcutsDeleted.length}` + ) + } catch (err) { + // Restore must never be silently skipped — surface it loudly and point at the + // manifest so the shared keys can be recovered by hand. + console.error( + `\n*** WIN-UPDATE-E2E: registry/shortcut restore THREW: ${err.stack || err.message}\n` + + ` Recover manually from the backups under ${manifest?.backupDir}. ***\n` + ) + } + + removeDirIfEmpty(installDir) + rmSync(runDir, { recursive: true, force: true }) +} + +/** Remove a directory only if it is empty (best-effort; leaves non-empty dirs). */ +function removeDirIfEmpty(dir) { + try { + if (existsSync(dir) && readdirSync(dir).length === 0) { + rmSync(dir, { recursive: true, force: true }) + } + } catch { + /* leave it in place */ + } +} + +async function teardown({ + app, + created, + userDataDir, + keepInstall, + hadPreexistingInstall, + installedExePath, + runDir +}) { + try { + await closeApp(app) + } catch { + /* already closed */ + } + // Kill ONLY processes this harness created: the marker and this run's daemon + // (scoped to the isolated userData). Never touch pre-existing user processes. + killPid(created.markerPid) + for (const pid of resolveScopedDaemon(userDataDir).pids) { + killPid(pid) + } + for (const pid of created.daemonPids) { + killPid(pid) + } + + if (keepInstall) { + log('teardown', `--keep-install set; leaving install + ${runDir}`) + return + } + // Only uninstall when the harness fully OWNS the install (no pre-existing + // build). When it overwrote a developer's existing install, leave it in place + // — uninstalling would remove a build we did not put there. + if (hadPreexistingInstall) { + console.log( + '\n*** NOTE: an Orca install existed before this run. It was OVERWRITTEN and the\n' + + ' --to version is now installed. Your prior build was NOT restored — reinstall\n' + + ' your intended build if needed. Skipping uninstall. ***\n' + ) + } else if (installedExePath) { + // Uninstall ONLY the exact directory the harness installed into this run, + // with the explicit default-location opt-in (non-isolated mode legitimately + // installs to the default path and owns it here). Never scan-and-discover. + const uninstalled = silentUninstall(path.dirname(installedExePath), { + allowDefaultLocation: true + }) + log('teardown', `uninstalled: ${uninstalled}`) + } else { + log('teardown', 'no install path recorded; skipping uninstall (nothing owned to remove)') + } + rmSync(runDir, { recursive: true, force: true }) +} + +function killPid(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return + } + try { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } catch { + /* already dead */ + } +} + +function readIntFile(filePath) { + try { + const n = Number(readFileSync(filePath, 'utf8').trim()) + return Number.isInteger(n) ? n : null + } catch { + return null + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Only run the orchestrator when invoked directly, so the module (and +// readDaemonLog) can be imported by tests without kicking off a real install. +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main() + .then((code) => { + // Why: force-exit. A launched Electron app or the window-watch child can + // keep libuv handles open; without this the process lingers to the CI job + // timeout instead of exiting when the run is logically done. + process.exit(code) + }) + .catch((err) => { + console.error('[win-update-e2e] FATAL:', err.stack || err.message) + process.exit(1) + }) +} diff --git a/tools/win-update-e2e/window-enum.ps1 b/tools/win-update-e2e/window-enum.ps1 new file mode 100644 index 00000000000..c628ae11546 --- /dev/null +++ b/tools/win-update-e2e/window-enum.ps1 @@ -0,0 +1,122 @@ +<# + window-enum.ps1 — enumerate visible top-level windows with owner attribution. + + Dot-source this file, then call Get-VisibleTopLevelWindows. It returns objects + { handle, pid, processName, title } for every visible, non-cloaked top-level + window that has a title. + + WHY window enumeration (and never conhost command-line heuristics): the July + 2026 post-mortem proved that (a) conhost flag interpretation inverts depending + on the parent's console state, and (b) MainWindowHandle is 0 for + Windows-Terminal-hosted consoles, so handle-based "is it visible" checks read + a visibly-flashing window as hidden. The only sound signal is a real visible + top-level window, attributed to an owner process and (for our own children) a + canary title. This function is the single instrument every probe shares. +#> + +# Compile the P/Invoke enumerator once. Guard on the type already existing so +# repeated dot-sourcing inside the watch loop does not re-run Add-Type (which +# throws on a duplicate type). +if (-not ('OrcaWinEnum.Native' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace OrcaWinEnum { + public static class Native { + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] + private static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("dwmapi.dll")] + private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + + // DWMWA_CLOAKED: a window can be IsWindowVisible()==true yet cloaked by the + // shell (e.g. background UWP hosts). Cloaked windows are not really on + // screen, so we skip them to keep the baseline diff free of ghost churn. + private const int DWMWA_CLOAKED = 14; + + public static string[] Enumerate() { + var rows = new List(); + EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) { + if (!IsWindowVisible(hWnd)) { return true; } + int len = GetWindowTextLength(hWnd); + if (len <= 0) { return true; } + int cloaked = 0; + try { DwmGetWindowAttribute(hWnd, DWMWA_CLOAKED, out cloaked, sizeof(int)); } catch { } + if (cloaked != 0) { return true; } + var sb = new StringBuilder(len + 1); + GetWindowText(hWnd, sb, sb.Capacity); + uint pid; + GetWindowThreadProcessId(hWnd, out pid); + // Tab-separated: handle, pid, title. Titles never contain a tab, and + // the handle/pid are numeric, so this parses unambiguously downstream. + rows.Add(((long)hWnd).ToString() + "\t" + pid.ToString() + "\t" + sb.ToString()); + return true; + }, IntPtr.Zero); + return rows.ToArray(); + } + } +} +'@ +} + +function Get-VisibleTopLevelWindows { + # @() forces an array even when Enumerate() returns a single row — the PS 5.1 + # single-item unwrap pitfall that caused a production incident when a count + # of "1" silently became a scalar. + $rows = @([OrcaWinEnum.Native]::Enumerate()) + + # Cache pid -> process name so we resolve each owning process at most once. + $nameByPid = @{} + $result = New-Object System.Collections.Generic.List[object] + + foreach ($row in $rows) { + $parts = $row -split "`t", 3 + if ($parts.Count -lt 3) { continue } + $handle = [long]$parts[0] + $procId = [int]$parts[1] + $title = $parts[2] + + if (-not $nameByPid.ContainsKey($procId)) { + $procName = $null + try { + $procName = (Get-Process -Id $procId -ErrorAction Stop).ProcessName + } catch { + $procName = $null + } + $nameByPid[$procId] = $procName + } + + $result.Add([pscustomobject]@{ + handle = $handle + pid = $procId + processName = $nameByPid[$procId] + title = $title + }) + } + + # Return the raw array; callers wrap with @() to normalize the PS 5.1 + # single-item unwrap. (Do not use the comma operator here — combined with a + # caller's @() it produces a nested array.) + return $result.ToArray() +} + +# When run directly (not dot-sourced) emit the snapshot as JSON so this file +# doubles as the baseline-snapshot tool. Wrapped in an object with a `windows` +# array; the JS side normalizes single-element results because PS 5.1 +# ConvertTo-Json unwraps a one-element array to a bare object. +if ($MyInvocation.InvocationName -ne '.') { + [pscustomobject]@{ windows = @(Get-VisibleTopLevelWindows) } | + ConvertTo-Json -Depth 4 -Compress +} diff --git a/tools/win-update-e2e/window-watch.mjs b/tools/win-update-e2e/window-watch.mjs new file mode 100644 index 00000000000..1f35ef8b276 --- /dev/null +++ b/tools/win-update-e2e/window-watch.mjs @@ -0,0 +1,174 @@ +// Node wrapper around window-watch.ps1: start/stop the background window watch +// and parse its JSONL event log. Also runnable standalone as `--selftest`, +// which opens a real transient PowerShell window and asserts the watch caught +// it — so the harness's own instrument is testable without any installers. + +import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { assertWin32 } from './platform-guard.mjs' +import { runScriptFileJson, spawnScriptFile, runCommandSync } from './powershell-runner.mjs' + +const HERE = import.meta.dirname +const WATCH_SCRIPT = path.join(HERE, 'window-watch.ps1') +const ENUM_SCRIPT = path.join(HERE, 'window-enum.ps1') + +/** + * Take a one-shot baseline snapshot of visible top-level windows and write it + * to baselinePath. Returns the window array. Normalizes the PS 5.1 single-item + * unwrap (windows may arrive as one object or as a nested array). + */ +export function captureBaseline(baselinePath) { + assertWin32('window-watch baseline') + const parsed = runScriptFileJson(ENUM_SCRIPT) + const windows = normalizeWindows(parsed.windows) + writeFileSync(baselinePath, JSON.stringify({ windows }, null, 2)) + return windows +} + +function normalizeWindows(raw) { + if (!raw) { + return [] + } + // PS 5.1 ConvertTo-Json can hand back a single object, a flat array, or (from + // a stray comma operator) a one-element array wrapping the real array. + if (Array.isArray(raw)) { + if (raw.length === 1 && Array.isArray(raw[0])) { + return raw[0] + } + return raw + } + return [raw] +} + +/** + * Start the background watch. Returns a handle with stop() -> events array. + * The watch seeds from baselinePath and records new windows to outPath until + * stop() (which drops a stop-file) or durationSec elapses. + */ +export function startWatch({ baselinePath, outPath, stopFile, durationSec = 600, pollMs = 500 }) { + assertWin32('window-watch') + const resolvedStopFile = stopFile ?? `${outPath}.stop` + if (existsSync(resolvedStopFile)) { + rmSync(resolvedStopFile) + } + + const child = spawnScriptFile(WATCH_SCRIPT, [ + '-BaselinePath', + baselinePath, + '-OutPath', + outPath, + '-StopFile', + resolvedStopFile, + '-DurationSec', + String(durationSec), + '-PollMs', + String(pollMs), + '-EnumScript', + ENUM_SCRIPT + ]) + + let stderr = '' + child.stderr.on('data', (d) => { + stderr += d.toString() + }) + + const exited = new Promise((resolve) => child.once('exit', resolve)) + + return { + process: child, + stopFile: resolvedStopFile, + outPath, + async stop() { + // Signal the loop to end, then wait for it to flush and exit. Fall back + // to a hard kill if the loop is wedged so a stuck watch never hangs teardown. + writeFileSync(resolvedStopFile, 'stop') + const timer = setTimeout(() => child.kill(), 5000) + await exited + clearTimeout(timer) + return { events: readEvents(outPath), stderr } + } + } +} + +/** Parse a window-watch JSONL log into an array of event objects. */ +export function readEvents(outPath) { + if (!existsSync(outPath)) { + return [] + } + return readFileSync(outPath, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)] + } catch { + return [] + } + }) +} + +async function selftest() { + assertWin32('window-watch --selftest') + const dir = mkdtempSync(path.join(tmpdir(), 'orca-winwatch-selftest-')) + const baselinePath = path.join(dir, 'baseline.json') + const outPath = path.join(dir, 'events.jsonl') + const canary = `ORCA-E2E-SELFTEST-${Date.now()}` + + console.log(`[selftest] baseline snapshot -> ${baselinePath}`) + const baseline = captureBaseline(baselinePath) + console.log(`[selftest] baseline captured ${baseline.length} visible windows`) + + const watch = startWatch({ baselinePath, outPath, durationSec: 20, pollMs: 300 }) + // Give the watch a moment to compile Add-Type and take its first poll before + // the transient window appears, so the appearance is genuinely "new". + await delay(1500) + + console.log(`[selftest] opening transient window titled ${canary}`) + // Must be a real console window: Start-Process allocates one, whereas a + // detached Node spawn gets DETACHED_PROCESS (no console at all) and would + // never appear in enumeration. This mirrors how a daemon child that lacks + // CREATE_NO_WINDOW allocates a fresh visible console — exactly the flash the + // real harness must catch. + const transientScript = path.join(dir, 'transient.ps1') + writeFileSync(transientScript, `$host.UI.RawUI.WindowTitle='${canary}'; Start-Sleep -Seconds 5`) + runCommandSync( + `Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile','-File','${transientScript}'` + ) + + // Poll cycles at 300ms; 3.5s covers several polls while the window is alive. + await delay(3500) + const { events, stderr } = await watch.stop() + rmSync(dir, { recursive: true, force: true }) + + const caught = events.filter((e) => typeof e.title === 'string' && e.title.includes(canary)) + console.log(`[selftest] watch recorded ${events.length} new windows total`) + if (stderr.trim()) { + console.log(`[selftest] watch stderr:\n${stderr.trim()}`) + } + if (caught.length === 0) { + console.error( + `[selftest] FAIL: watch did not capture a window titled "${canary}". ` + + `New windows seen: ${JSON.stringify(events.map((e) => e.title))}` + ) + process.exitCode = 1 + return + } + console.log(`[selftest] PASS: caught canary window: ${JSON.stringify(caught[0])}`) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + if (process.argv.includes('--selftest')) { + selftest().catch((err) => { + console.error(err) + process.exitCode = 1 + }) + } else { + console.log('Usage: node window-watch.mjs --selftest') + } +} diff --git a/tools/win-update-e2e/window-watch.ps1 b/tools/win-update-e2e/window-watch.ps1 new file mode 100644 index 00000000000..7b1b73a5c2d --- /dev/null +++ b/tools/win-update-e2e/window-watch.ps1 @@ -0,0 +1,91 @@ +<# + window-watch.ps1 — poll visible top-level windows and record every NEW one. + + Runs a tight loop (default 500ms) that diffs the current visible top-level + windows against a baseline snapshot, appending any window handle it has not + seen before to a JSONL file as one event per line: + { "ts": "", "handle": , "pid": , "processName": "...", "title": "..." } + + It stops when -DurationSec elapses or -StopFile appears (whichever comes + first), so the orchestrator can end the watch deterministically after the + post-relaunch soak window. + + Attribution is by owner process + title only (see window-enum.ps1 for why + conhost heuristics are invalid). Classification of which new windows count as + "unexpected" (canary title, terminal/console owner) is done by the JS + assertions layer against this raw event log — this probe records everything. +#> +param( + [Parameter(Mandatory = $true)][string]$BaselinePath, + [Parameter(Mandatory = $true)][string]$OutPath, + [string]$StopFile = '', + [int]$DurationSec = 600, + [int]$PollMs = 500, + [string]$EnumScript = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not $EnumScript) { + $EnumScript = Join-Path $PSScriptRoot 'window-enum.ps1' +} +. $EnumScript + +# Seed the baseline handle set so pre-existing windows never register. Their +# later title churn (clocks, tab names) is irrelevant noise, so baseline +# handles are excluded outright. Baseline JSON is { windows: [ { handle, ... } ] }; +# tolerate the PS 5.1 single-element unwrap by wrapping with @(). +$baselineHandles = New-Object System.Collections.Generic.HashSet[long] +if (Test-Path $BaselinePath) { + $baseline = Get-Content -Raw $BaselinePath | ConvertFrom-Json + foreach ($w in @($baseline.windows)) { + if ($null -ne $w -and $null -ne $w.handle) { + [void]$baselineHandles.Add([long]$w.handle) + } + } +} + +# Track the last-seen title of each NEW window. A window is emitted on first +# sighting ("appear") and again whenever its title changes ("retitle"): a real +# flashing console often opens with a generic title (e.g. WindowsTerminal's +# "Terminal") and only later shows our child's canary title, so title evolution +# must be captured or canary attribution is missed. +$titleByHandle = @{} + +# Truncate/create the output file up front so the reader can always open it. +[System.IO.File]::WriteAllText($OutPath, '') + +$deadline = (Get-Date).AddSeconds($DurationSec) + +while ((Get-Date) -lt $deadline) { + if ($StopFile -and (Test-Path $StopFile)) { break } + + $windows = @(Get-VisibleTopLevelWindows) + $now = (Get-Date).ToString('o') + foreach ($w in $windows) { + $handle = [long]$w.handle + if ($baselineHandles.Contains($handle)) { continue } + + $title = [string]$w.title + $prior = $null + $isNew = -not $titleByHandle.ContainsKey($handle) + if (-not $isNew) { $prior = $titleByHandle[$handle] } + if ($isNew -or $prior -ne $title) { + $titleByHandle[$handle] = $title + $event = [pscustomobject]@{ + ts = $now + kind = if ($isNew) { 'appear' } else { 'retitle' } + handle = $handle + pid = $w.pid + processName = $w.processName + title = $title + } + $line = ($event | ConvertTo-Json -Compress -Depth 3) + # AppendAllText with an explicit newline keeps each event on its own line + # even if the process is killed mid-write (no buffered partial records). + [System.IO.File]::AppendAllText($OutPath, $line + "`n") + } + } + + Start-Sleep -Milliseconds $PollMs +} From 79d0de9c33220d97bd3ecc06ff528f6e8448a3cb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:42:43 -0700 Subject: [PATCH 043/152] fix(terminal): remote query-reply corruption (#7329) + snapshot grid-width repaint (#7279) (#7736) Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.ts | 8 +- src/main/daemon/headless-emulator.ts | 18 +- ...ro-7329-remote-snapshot-corruption.test.ts | 157 +++++++++++++++ src/main/daemon/types.ts | 6 + src/main/ipc/pty.ts | 1 + src/main/runtime/orca-runtime.ts | 10 + src/main/runtime/rpc/methods/terminal.ts | 5 + .../terminal-multiplex-escape-tail.test.ts | 125 ++++++++++++ src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../terminal-pane/pty-connection.test.ts | 187 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 99 +++++++++- .../terminal-pane/pty-transport-types.ts | 21 +- .../terminal-pane/pty-transport.test.ts | 45 +++++ .../components/terminal-pane/pty-transport.ts | 30 ++- .../remote-runtime-pty-batching.ts | 5 + ...-runtime-pty-query-reply-immediate.test.ts | 142 +++++++++++++ ...e-runtime-pty-snapshot-escape-tail.test.ts | 116 +++++++++++ .../remote-runtime-pty-transport.ts | 54 ++++- .../remote-runtime-terminal-multiplexer.ts | 19 +- .../terminal-partial-escape-tail.test.ts | 86 ++++++++ src/shared/terminal-partial-escape-tail.ts | 149 ++++++++++++++ src/shared/terminal-query-reply.test.ts | 81 ++++++++ src/shared/terminal-query-reply.ts | 69 +++++++ 24 files changed, 1412 insertions(+), 23 deletions(-) create mode 100644 src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts create mode 100644 src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts create mode 100644 src/shared/terminal-partial-escape-tail.test.ts create mode 100644 src/shared/terminal-partial-escape-tail.ts create mode 100644 src/shared/terminal-query-reply.test.ts create mode 100644 src/shared/terminal-query-reply.ts diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 56a775a8c99..648870abe1c 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -316,7 +316,13 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, isReattach: true, - isAlternateScreen: isAltScreen + isAlternateScreen: isAltScreen, + // Why: carry the mid-escape tail so the renderer can write it after the + // reattach reset — without it the local daemon reattach path renders a + // split escape's continuation literally, unlike the remote path (#7329). + ...(result.snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: result.snapshot.pendingEscapeTailAnsi } + : {}) } } diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index fb0a9c190d4..74381d3d543 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,6 +3,7 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -36,6 +37,13 @@ export class HeadlessEmulator { private oscScanTail = '' private privateModes = new TerminalPrivateModeTracker() private restoredOscLinks: TerminalOscLinkRange[] = [] + // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's + // parser, not the screen buffer, so serialize() drops them and the next + // chunk's continuation renders literally after a remote snapshot restore + // (#7329). Track the unparsed trailing partial at ingest (committed after + // xterm parses the same bytes, like the private-mode mirror) and ship it in + // the snapshot so the restorer can complete the sequence. + private partialEscapeTail = '' private disposed = false private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean @@ -89,6 +97,7 @@ export class HeadlessEmulator { // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. this.privateModes.scan(data) + this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) }) @@ -115,6 +124,7 @@ export class HeadlessEmulator { // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) this.privateModes.scan(data) + this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } @@ -164,7 +174,13 @@ export class HeadlessEmulator { cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined + lastTitle: this.lastTitle ?? undefined, + // Why: written LAST by the restorer (after any reset) so the next live + // chunk completes this dangling sequence instead of rendering it literally + // (#7329). Its bytes are already counted by the snapshot seq. + ...(this.partialEscapeTail.length > 0 + ? { pendingEscapeTailAnsi: this.partialEscapeTail } + : {}) } } diff --git a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts new file mode 100644 index 00000000000..34a0e095ee1 --- /dev/null +++ b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { HeadlessEmulator } from './headless-emulator' + +// Repro for #7329: "remote server + terminal" — typing gets escape sequences +// injected/wrapped around it and follow-up commands are corrupted. +// +// The remote-server path serializes terminal state on the daemon via +// HeadlessEmulator.getSnapshot() (SerializeAddon + buildRehydrateSequences) and +// replays it into the renderer xterm, then applies POST_REPLAY_REATTACH_RESET. +// This test drives the REAL daemon serializer and a REAL renderer-side xterm to +// see what the user's terminal ends up looking like after a subscribe/reattach. + +const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' +const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' +// Verbatim from layout-serialization.ts (the reattach path the remote onSnapshot uses). +const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` + +function writeXterm(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function emulatorWrite(emu: HeadlessEmulator, data: string): Promise { + await emu.write(data) +} + +/** Replay a daemon snapshot into a renderer xterm exactly like the remote + * onSnapshot → replayDataCallback path: clear, write rehydrate+snapshot, + * then the reattach mode reset. */ +async function replayRemoteSnapshot( + term: Terminal, + snapshot: { rehydrateSequences: string; snapshotAnsi: string; pendingEscapeTailAnsi?: string } +): Promise { + await writeXterm(term, '\x1b[2J\x1b[3J\x1b[H') + await writeXterm(term, snapshot.rehydrateSequences + snapshot.snapshotAnsi) + await writeXterm(term, POST_REPLAY_REATTACH_RESET) + // The fix: the restorer writes the pending mid-escape tail LAST, after the + // reset (mirrors drainReplayDataQueue in pty-connection.ts). + if (snapshot.pendingEscapeTailAnsi) { + await writeXterm(term, snapshot.pendingEscapeTailAnsi) + } +} + +function renderVisible(term: Terminal): string { + const buf = term.buffer.active + const lines: string[] = [] + for (let y = 0; y < term.rows; y += 1) { + lines.push(buf.getLine(buf.viewportY + y)?.translateToString(true) ?? '') + } + return lines.join('\n').replace(/\s+$/g, '') +} + +describe('#7329 remote-server snapshot corruption', () => { + it('leaves bracketed-paste + mouse modes armed after a reattach snapshot', async () => { + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + // A live remote shell that armed bracketed paste (bash 4.4+/readline + // default) and vt200+SGR mouse (a TUI the user just exited). + await emulatorWrite(emu, '\x1b[?2004h\x1b[?1000h\x1b[?1006h') + await emulatorWrite(emu, 'user@host:~$ ') + + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + // The daemon snapshot re-arms the modes it observed. + expect(snapshot.rehydrateSequences).toContain('\x1b[?2004h') + expect(snapshot.modes.bracketedPaste).toBe(true) + expect(snapshot.modes.mouseTracking).toBe(true) + + await replayRemoteSnapshot(term, snapshot) + + // After the remote reattach reset, the renderer xterm is STILL in + // bracketed-paste + mouse mode — even though POST_REPLAY_REATTACH_RESET + // ran. Cold restore's POST_REPLAY_MODE_RESET would have cleared these. + expect(term.modes.bracketedPasteMode).toBe(true) // leak + expect(term.modes.mouseTrackingMode).not.toBe('none') // leak + } finally { + emu.dispose() + term.dispose() + } + }) + + it('drops a mid-escape tail so continuation bytes render literally after reattach', async () => { + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + // A remote PTY read that ends mid-escape: the shell was about to paint a + // colored prompt but the read boundary split the SGR sequence. The next + // read carries the continuation. The daemon serializes BETWEEN the two + // reads (e.g. the client subscribes/reattaches right then). + await emulatorWrite(emu, 'first line\r\n') + await emulatorWrite(emu, '\x1b[3') // <-- partial SGR: "\x1b[38;5;...m" started + + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + // The serializer still cannot put the dangling "\x1b[3" in the screen ANSI + // (it lives in the parser)... + expect(snapshot.snapshotAnsi).not.toContain('\x1b[3') + // ...but the emulator now ships it as a separate pending-escape tail. + expect(snapshot.pendingEscapeTailAnsi).toBe('\x1b[3') + + await replayRemoteSnapshot(term, snapshot) + + // The continuation of the split escape arrives as the next live chunk. + await writeXterm(term, '8;5;196mred$ yes\r\n') + + const visible = renderVisible(term) + // FIXED: the tail was replayed after the reset, so the continuation + // "8;5;196m" completes the SGR escape and is consumed — not rendered + // literally. The visible text is just the prompt + typed command. + expect(visible).not.toContain('8;5;196m') + expect(visible).toContain('red$ yes') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('does not eat the continuation byte when the tail and continuation are contiguous', async () => { + // The safety property the fix relies on (mirrors the snapshot-seq accounting + // in orca-runtime/getOutputAfterSnapshotSeq): the snapshot seq counts the + // tail bytes, so the FIRST live chunk after the snapshot is the exact + // continuation and completes the dangling sequence with no eaten byte. + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await emulatorWrite(emu, '\x1b[38;5;') // dangling: params so far + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + expect(snapshot.pendingEscapeTailAnsi).toBe('\x1b[38;5;') + await replayRemoteSnapshot(term, snapshot) + // Continuation completes the SGR then prints a visible token. + await writeXterm(term, '82mHELLO') + const visible = renderVisible(term) + // No byte of "82m" leaks; the token renders whole (color applied). + expect(visible).toBe('HELLO') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('documents the residual edge: an idle-death tail eats the next output byte', async () => { + // KNOWN, ACCEPTED trade-off: if a mid-escape read is the LAST thing the + // process emits (it dies/idles and never sends the continuation), the tail + // sits armed in the parser and the NEXT unrelated output loses its first + // byte. This is strictly narrower than the bug it fixes (which garbled every + // real split escape), and pre-fix such a stream simply dropped the partial. + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeXterm(term, 'prompt$ ') + await writeXterm(term, '\x1b[3') // armed tail, no continuation ever comes + await writeXterm(term, 'yes\r\n') // unrelated later output + // The 'y' is absorbed as a CSI parameter of the dangling sequence. + expect(renderVisible(term)).toBe('prompt$ es') + } finally { + term.dispose() + } + }) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a38cc21a425..10d27f2c8e0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -25,6 +25,12 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** The trailing partial escape sequence left unparsed in the emulator when a + * PTY read ended mid-escape. serialize() cannot carry it (it lives in the + * parser, not the buffer), so the restorer must write it LAST — after any + * post-snapshot reset — so the next live chunk's continuation completes the + * sequence instead of rendering literally (#7329). */ + pendingEscapeTailAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 79739fd27ac..f63b43719f0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2614,6 +2614,7 @@ export function registerPtyHandlers( seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 52d248170ca..ba2b4d946aa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5707,6 +5707,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } @@ -5741,6 +5742,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -6021,6 +6023,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { @@ -6138,6 +6141,10 @@ export class OrcaRuntimeService { source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + // Why: dangling mid-escape tail the restorer must write LAST, after any + // reset, so the next live chunk completes it instead of rendering it + // literally (#7329). + pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { @@ -6166,6 +6173,9 @@ export class OrcaRuntimeService { seq: state.outputSequence, source: 'headless', oscLinks: snapshot.oscLinks, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 48b1bc793b2..4ebe781495f 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -55,6 +55,7 @@ type SnapshotFrameOptions = { truncatedByByteBudget?: boolean source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] + pendingEscapeTailAnsi?: string } type SerializedSnapshot = { @@ -67,6 +68,7 @@ type SerializedSnapshot = { oscLinks?: TerminalOscLinkRange[] scrollbackRows: number truncatedByByteBudget: boolean + pendingEscapeTailAnsi?: string } | null type TerminalViewportClient = { @@ -466,6 +468,7 @@ function sendSnapshotFrames( cwd: options.cwd, source: options.source, oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) @@ -1398,6 +1401,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cwd: serialized?.cwd, source: serialized?.source, oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, data: serialized?.data ?? '' @@ -1602,6 +1606,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') }) // Why: baseline for resize re-stream gating; the client already diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts new file mode 100644 index 00000000000..ab255856216 --- /dev/null +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../shared/terminal-stream-protocol' + +// Transport-level regression for #7329: the daemon's mid-escape tail +// (pendingEscapeTailAnsi) must survive the terminal.multiplex wire so the +// renderer can replay it after the reset. Without threading it through the +// SnapshotStart JSON frame, the tail is lost and the next live chunk renders +// literally ("colors/garbage around what I type"). + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { + it('carries the daemon pendingEscapeTailAnsi through the SnapshotStart frame', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'user@host:~$ ', + cols: 80, + rows: 24, + // The dangling partial the emulator could not serialize. + pendingEscapeTailAnsi: '\x1b[3' + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.runOnlyPendingTimersAsync() + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 5, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + + // Drive the subscribe promise chain (readTerminal/serialize awaits + the + // output-batcher flush timer) to completion. + for (let i = 0; i < 5; i += 1) { + await vi.runOnlyPendingTimersAsync() + } + + const snapshotStart = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! + expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ + pendingEscapeTailAnsi: '\x1b[3' + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 184e0aea648..10687c01a29 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1174,6 +1174,7 @@ export type PreloadApi = { seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index f0ef9cf7e34..4a24404831a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -850,6 +850,7 @@ const api = { seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ 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 78edfeeb596..9b43d897171 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -205,6 +205,7 @@ type MockTransport = { ) => unknown } sendInput: ReturnType + sendInputImmediate: ReturnType sendInputAccepted?: ReturnType resize: ReturnType getPtyId: ReturnType @@ -353,6 +354,9 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport serializeBuffer: undefined } as MockTransport const sendInput = transport.sendInput as unknown as (data: string) => boolean + // Why: query replies now route through sendInputImmediate; delegate to the + // same spy so assertions on reply delivery still observe them (#7329). + transport.sendInputImmediate = vi.fn((data: string) => sendInput(data)) transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data)) return transport } @@ -5247,6 +5251,189 @@ describe('connectPanePty', () => { ) }) + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { + // Why: the daemon serializes soft-wrapped lines as continuous text. Replaying + // that at the pane's current column count rewraps rows one cell early/late. + // The reattach path must resize xterm to the snapshot's grid before writing + // the snapshot bytes, so a remote pane whose size drifted from the daemon's + // grid still repaints the exact host layout. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1004hrestored snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + } + } as StoreState + + // createPane opens the pane at 120x40, deliberately different from the + // daemon snapshot's 80x24 grid. + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + // Pane was resized to the snapshot grid before the snapshot bytes landed. + expect(pane.terminal.resize).toHaveBeenCalledWith(80, 24) + const resizeToSnapshotCall = pane.terminal.resize.mock.invocationCallOrder.find( + (_order, index) => { + const [cols, rows] = pane.terminal.resize.mock.calls[index] + return cols === 80 && rows === 24 + } + ) + const snapshotWriteCall = pane.terminal.write.mock.invocationCallOrder.find( + (_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[?1004hrestored snapshot' + ) + expect(resizeToSnapshotCall).toBeDefined() + expect(snapshotWriteCall).toBeDefined() + expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) + }) + + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { + // Why: the mid-escape tail must be re-armed LAST — after the reattach reset, + // whose ESC would abort it — so the racing live continuation completes it + // instead of rendering literally. Covers the local daemon reattach path, + // which previously dropped the field the remote path already honored. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'restored snapshot', + snapshotCols: 80, + snapshotRows: 24, + pendingEscapeTailAnsi: '\x1b[3' + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const tailWriteCall = pane.terminal.write.mock.invocationCallOrder.find( + (_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[3' + ) + const resetWriteCall = pane.terminal.write.mock.invocationCallOrder.find((_order, index) => + String(pane.terminal.write.mock.calls[index][0]).includes(POST_REPLAY_REATTACH_RESET) + ) + expect(tailWriteCall).toBeDefined() + expect(resetWriteCall).toBeDefined() + // The dangling tail is written AFTER the reset. + expect(resetWriteCall as number).toBeLessThan(tailWriteCall as number) + }) + + it('routes native onData query replies through sendInputImmediate, typed input through sendInput (#7329)', async () => { + // Why this test: the mock transport delegates sendInputImmediate to the + // sendInput spy, so reply-delivery assertions elsewhere cannot tell the two + // apart — reverting the onData isTerminalQueryReply branch used to pass the + // whole suite. This pins the routing decision itself. + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-7329') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + + // xterm answers CSI 6n natively by emitting a CPR through onData, mixed + // with keystrokes. It must take the immediate path (skips the remote 8ms + // input debounce that corrupted it). + sendTerminalInputThroughPane(pane, '\x1b[3;1R') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') + + // Ordinary typed input must stay on the debounced path — never immediate. + transport.sendInputImmediate.mockClear() + sendTerminalInputThroughPane(pane, 'yes') + sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched + expect(transport.sendInput).toHaveBeenCalledWith('yes') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') + expect(transport.sendInputImmediate).not.toHaveBeenCalled() + }) + + it('writes the onReplayData pendingEscapeTailAnsi meta last, after the replayed bytes (#7329)', async () => { + // Why this test: the remote snapshot path delivers the daemon tail through + // transport callbacks.onReplayData meta into drainReplayDataQueue. That + // consumer (and the replayDataCallback meta threading before it) had no + // failing test — severing the meta pass-through kept the suite green. + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const pane = createPane(1) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) as typeof pane.terminal.write + const transport = createMockTransport('remote:web-env-1@@pty-7329-tail') + const replayCallback: { + current: + | (( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:web-env-1@@pty-7329-tail', replay: '' } + }) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + expect(replayCallback.current).toBeTypeOf('function') + + replayCallback.current?.('remote snapshot bytes', { + clearBeforeReplay: false, + pendingEscapeTailAnsi: '\x1b[3' + }) + await flushAsyncTicks(20) + + const snapshotIndex = writes.indexOf('remote snapshot bytes') + const tailIndex = writes.lastIndexOf('\x1b[3') + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(tailIndex).toBeGreaterThanOrEqual(0) + // The dangling tail is re-armed after the snapshot (and any reset), so the + // next live chunk's continuation completes it instead of rendering literally. + expect(tailIndex).toBeGreaterThan(snapshotIndex) + expect(writes.slice(tailIndex + 1)).toEqual([]) + }) + it('preserves live modes and injects focus-in after focused agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0e7da9d9493..2a6a968bf2f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -11,6 +11,7 @@ import { getWorktreeMapFromState } from '@/store/selectors' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' @@ -2847,13 +2848,16 @@ export function connectPanePty( const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ terminal: pane.terminal, parser: pane.terminal.parser, - sendInput: (data) => transport.sendInput(data), + // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode + // read window; the remote transport's input debounce would corrupt them + // (#7329), so send immediately. + sendInput: (data) => transport.sendInputImmediate(data), isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( pane.terminal, - (data) => transport.sendInput(data) + (data) => transport.sendInputImmediate(data) ) const onDataDisposable = pane.terminal.onData((data) => { @@ -2891,6 +2895,19 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } + // Why: xterm answers CPR/DSR/DA queries natively through this same onData + // stream (mixed with keystrokes). Those replies are latency-critical — a + // querying program reads them in raw mode with a short timeout — so send + // them immediately, skipping the remote input debounce that would corrupt + // them (#7329). They are not user input, so they bypass intent inference and + // activity recording below. No pending-intent guard: the only intents are + // plain-escape (`\x1b`) and ctrl-c (`\x03`), neither of which can satisfy + // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), + // so a real keystroke never reaches this branch. + if (isTerminalQueryReply(data)) { + transport.sendInputImmediate(data) + return + } const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -4040,13 +4057,14 @@ export function connectPanePty( type PendingReplayData = { data: string clearBeforeReplay: boolean + pendingEscapeTailAnsi?: string } let pendingReplayData: PendingReplayData | null = null let replayDrainQueued = false const drainReplayDataQueue = async (): Promise => { while (pendingReplayData !== null) { - const { data, clearBeforeReplay } = pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = pendingReplayData pendingReplayData = null // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames @@ -4064,6 +4082,14 @@ export function connectPanePty( await writeReplayDataAsync(reattachReplayResetSequence()) sendFocusedReattachFocusInAfterReplay() } + // Why: the daemon could not serialize a PTY read that ended mid-escape, + // so the emulator shipped the dangling partial separately. Write it LAST + // — after the reset, whose ESC would otherwise abort it — so the next + // live chunk completes the sequence instead of rendering literally + // (#7329). Guarded so a later ESC cannot leave the parser wedged. + if (pendingEscapeTailAnsi) { + await writeReplayDataAsync(pendingEscapeTailAnsi) + } if (disposed) { pendingReplayData = null return @@ -4074,10 +4100,14 @@ export function connectPanePty( manager.rebuildPaneWebgl(pane.id) } } - const replayDataCallback = (data: string, meta: { clearBeforeReplay?: boolean } = {}): void => { + const replayDataCallback = ( + data: string, + meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {} + ): void => { pendingReplayData = { data, - clearBeforeReplay: meta.clearBeforeReplay !== false + clearBeforeReplay: meta.clearBeforeReplay !== false, + ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}) } if (replayDrainQueued) { return @@ -4090,7 +4120,10 @@ export function connectPanePty( replayDrainQueued = false if (pendingReplayData !== null) { replayDataCallback(pendingReplayData.data, { - clearBeforeReplay: pendingReplayData.clearBeforeReplay + clearBeforeReplay: pendingReplayData.clearBeforeReplay, + ...(pendingReplayData.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: pendingReplayData.pendingEscapeTailAnsi } + : {}) }) } }) @@ -4182,7 +4215,9 @@ export function connectPanePty( // mode 2031 out-of-band so TUIs still render the snapshot with the same // theme-dependent styling they would have used in a visible pane. deps.paneMode2031Ref.current.set(pane.id, true) - transport.sendInput(mode2031SequenceFor(mode)) + // Why: a query reply — send immediately so the remote input debounce + // cannot delay it past the querying program's read window (#7329). + transport.sendInputImmediate(mode2031SequenceFor(mode)) deps.paneLastThemeModeRef.current.set(pane.id, mode) recordHiddenMode2031Reply() } @@ -4472,9 +4507,10 @@ export function connectPanePty( hiddenStartupRendererQueryPending = extracted.pending if (extracted.oscColorQueryData) { // Why: Codex's startup palette probe has a 100 ms budget. Answer - // hidden color queries directly so renderer scheduling cannot miss it. + // hidden color queries directly and immediately so neither renderer + // scheduling nor the remote input debounce (#7329) can miss it. sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInput(reply) + transport.sendInputImmediate(reply) ) } if (extracted.statelessQueryData) { @@ -4949,6 +4985,7 @@ export function connectPanePty( rows: number seq?: number alternateScreen?: boolean + pendingEscapeTailAnsi?: string }): void { const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) const colsBeforeReplay = pane.terminal.cols @@ -4989,6 +5026,13 @@ export function connectPanePty( } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: the snapshot was serialized while the emulator sat mid-escape; + // re-arm the dangling sequence as the FINAL replay write (any later ESC — + // including the reset above — would abort it) so the racing live tail's + // continuation completes it instead of rendering literally (#7329). + writeReplayData(snapshot.pendingEscapeTailAnsi) + } hiddenRendererStateDirty = false recordRendererOrderedSeq(snapshot) resetHiddenRendererRiskState() @@ -5250,7 +5294,9 @@ export function connectPanePty( sendTerminalOscColorQueryReplies( pendingForegroundQuery.oscColorQueryData, pane.terminal, - (reply) => transport.sendInput(reply) + // Why: OSC color reply — immediate so the remote debounce cannot delay + // it past the querying program's read window (#7329). + (reply) => transport.sendInputImmediate(reply) ) } const restoreAppliesToCurrentPty = @@ -5381,12 +5427,45 @@ export function connectPanePty( // and only the freshest source belongs on screen. if (connectResult?.snapshot) { rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + // Why: the daemon serializes its grid with soft-wrapped lines as + // continuous text. Replaying that at a different column count rewraps + // rows one cell early/late (bug #7279). Replay at the snapshot's own + // dimensions first; safeFit below fits the pane back and resizes the + // remote PTY. Suppress the xterm->PTY forward so this layout-only + // resize does not SIGWINCH the live remote TUI. Mirrors + // applyMainBufferSnapshot. + const snapshotCols = connectResult.snapshotCols + const snapshotRows = connectResult.snapshotRows + const hasSnapshotDimensions = + typeof snapshotCols === 'number' && + typeof snapshotRows === 'number' && + Number.isFinite(snapshotCols) && + Number.isFinite(snapshotRows) && + snapshotCols > 0 && + snapshotRows > 0 + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) + ) { + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshotCols, snapshotRows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode // reset. We only drop renderer-owned state that should not leak from // replay bytes into the restored renderer terminal. writeReplayData(reattachReplayResetSequence()) + if (connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the + // reset (whose ESC would abort it) so the racing live continuation + // completes it instead of rendering literally (#7329). + writeReplayData(connectResult.pendingEscapeTailAnsi) + } sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index f1e3b1b67b8..312945ed3f6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -18,6 +18,11 @@ export type PtyBufferSnapshot = { * scrollback lives in xterm and a clear destroys scroll-up after a tab * return. Mirrors the attach-time guard in pty-transport.ts. */ alternateScreen?: boolean + /** Trailing partial escape sequence the source emulator held mid-parse when + * the snapshot was taken. The restorer writes it LAST (after the reset) so a + * racing live continuation completes it instead of rendering literally + * (#7329). */ + pendingEscapeTailAnsi?: string } export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string } @@ -32,13 +37,20 @@ export type PtyConnectResult = { sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } replay?: string + /** Trailing partial escape the daemon emulator held mid-parse; the reattach + * replay writes it LAST (after the reset) so a racing live continuation + * completes it instead of rendering literally (#7329). */ + pendingEscapeTailAnsi?: string } type PtyCallbacks = { onConnect?: () => void onDisconnect?: () => void onData?: (data: string, meta?: PtyDataMeta) => void - onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void + onReplayData?: ( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ) => void onStatus?: (shell: string) => void onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void @@ -67,6 +79,13 @@ export type PtyTransport = { }) => void disconnect: () => void sendInput: (data: string) => boolean + // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel + // size) must skip input coalescing — a querying program reads them in raw + // mode with a short timeout, so a debounced reply lands on the shell prompt + // and corrupts input (#7329). Local transports already write promptly, so + // this is `sendInput` for them; the remote transport flushes pending input + // (preserving order) and sends the reply immediately. + sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise resize: ( cols: number, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index eb526d201f4..dc0096d899d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1026,6 +1026,51 @@ describe('createIpcPtyTransport', () => { }) }) + it('threads the daemon pendingEscapeTailAnsi through the reattach connect result (#7329)', async () => { + // Why: the local daemon ships the mid-escape tail on the spawn/reattach + // result; dropping it here silently regressed the local half of #7329 + // (the consumer test injects at the transport boundary, so only this + // asserts the IPC threading). + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnMock = vi.fn().mockResolvedValue({ + id: 'pty-reattach-tail', + isReattach: true, + snapshot: 'snapshot data', + snapshotCols: 80, + snapshotRows: 24, + pendingEscapeTailAnsi: '\x1b[3' + }) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + spawn: spawnMock, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } + } + } as unknown as typeof window + + const transport = createIpcPtyTransport() + const result = await transport.connect({ + url: '', + sessionId: 'pty-reattach-tail', + callbacks: {} + }) + + expect(result).toMatchObject({ + id: 'pty-reattach-tail', + pendingEscapeTailAnsi: '\x1b[3' + }) + }) + it('kills a PTY that finishes spawning after the transport was destroyed', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index c85138cc7be..6e6c80a3932 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -85,6 +85,10 @@ type ProcessPtyOutputOptions = { replayingBufferedData?: boolean suppressAttentionEvents?: boolean clearBeforeReplay?: boolean + // Why: a mid-escape tail the daemon could not serialize. The replay consumer + // must write it LAST, after the post-replay reset, so the next live chunk + // completes it instead of rendering literally (#7329). + pendingEscapeTailAnsi?: string } type PendingPtySideEffect = { @@ -402,8 +406,16 @@ export function createPtyOutputProcessor({ // session into the live store. The parser still consumes the bytes so they // do not leak into xterm, we just suppress the callback. if (options.replayingBufferedData && callbacks.onReplayData) { - if (options.clearBeforeReplay === false) { - callbacks.onReplayData(data, { clearBeforeReplay: false }) + const replayMeta = { + ...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}), + ...(options.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: options.pendingEscapeTailAnsi } + : {}) + } + // Why: preserve the bare-data call shape when there is no replay metadata, + // so eager-buffer replay (which passes neither) is unchanged. + if (Object.keys(replayMeta).length > 0) { + callbacks.onReplayData(data, replayMeta) } else { callbacks.onReplayData(data) } @@ -671,7 +683,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, - replay: spawnResult.replay + replay: spawnResult.replay, + pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi } satisfies PtyConnectResult } if (spawnResult.launchConfig) { @@ -849,6 +862,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return inputWriteQueue.enqueue(ptyId, data) }, + // Why: the local write queue already drains a lone item in the same turn + // (no wall-clock debounce), so query replies are prompt without special + // handling. Kept as a distinct method so callers express intent and the + // remote transport can override with its flush-then-send behavior (#7329). + sendInputImmediate(data: string): boolean { + if (!connected || !ptyId) { + return false + } + return inputWriteQueue.enqueue(ptyId, data) + }, + ...(connectionId ? {} : { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index 8e1ee1b0ab5..48a37e4b9d7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -8,6 +8,7 @@ import { export type RemoteRuntimePtyBatcher = { push: (data: string) => boolean + hasPendingValidation: () => boolean drain: () => Promise takePending: () => string flush: () => void @@ -145,6 +146,10 @@ export function createRemoteRuntimePtyTextBatcher( enqueueValidatedInput(data, tooLarge) return true }, + // Why: earlier input can be mid async byte-length validation and not yet in + // `pending`. `takePending()` cannot see it, so callers that must preserve + // byte order (sendInputImmediate) check this before bypassing the queue. + hasPendingValidation: (): boolean => validationTail !== null, drain, takePending, flush, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts new file mode 100644 index 00000000000..8060b32b7c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' + +// Regression for #7329: terminal query replies must NOT sit behind the remote +// input debounce (REMOTE_TERMINAL_INPUT_FLUSH_MS). transport.sendInputImmediate +// sends without arming the 8ms timer, so a reply beats the querying program's +// raw-mode read window; transport.sendInput stays debounced for typed input. + +describe('remote transport sendInputImmediate (#7329)', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.clearAllMocks() + subscriptionCallbacks = null + subscriptionSendBinary.mockReset() + runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } + } + }) + }) + + function terminalSendCalls(): unknown[] { + return runtimeCall.mock.calls + .map((call) => call[0] as { method?: string; params?: { text?: string } }) + .filter((args) => args.method === 'terminal.send') + } + + it('sends a query reply immediately, but debounces typed input by 8ms', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + + // Typed input: debounced — nothing sent before the 8ms flush. + expect(transport.sendInput('yes')).toBe(true) + expect(terminalSendCalls()).toEqual([]) + + // Query reply (OSC 11 background color): sent immediately, no timer. + expect(transport.sendInputImmediate('\x1b]11;rgb:2828/2c2c/3434\x1b\\')).toBe(true) + await Promise.resolve() + + // The immediate send flushed pending typed input ahead of the reply + // (preserving byte order) in a single send, without advancing timers. + const sends = terminalSendCalls() as { params: { text: string } }[] + expect(sends).toHaveLength(1) + expect(sends[0]?.params.text).toBe('yes\x1b]11;rgb:2828/2c2c/3434\x1b\\') + } finally { + vi.useRealTimers() + } + }) + + it('sends an immediate reply even with no pending typed input', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + + expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) // CPR reply + await Promise.resolve() + + const sends = terminalSendCalls() as { params: { text: string } }[] + expect(sends).toHaveLength(1) + expect(sends[0]?.params.text).toBe('\x1b[3;1R') + } finally { + vi.useRealTimers() + } + }) + + it('does not reorder a query reply ahead of a large paste still in async validation', async () => { + // #7736 review: a paste over the deferred-measurement threshold sits in the + // batcher's async validationTail (not in `pending`). sendInputImmediate must + // not send the reply ahead of it and reorder bytes on the wire. + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + + // A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async + // validation path, so its bytes are captured in validationTail, not pending. + const paste = 'p'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + expect(transport.sendInput(paste)).toBe(true) + // Immediately (validation still pending) a TUI emits a CPR reply. + expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) + + // Wait until the reply is actually flushed instead of sleeping a fixed + // interval: the reply legitimately trails the paste's async validation plus + // one debounce tick, and a fixed sleep raced that chain under CI load. + const combined = (): string => + (terminalSendCalls() as { params: { text: string } }[]).map((s) => s.params.text).join('') + await expect.poll(combined, { timeout: 5000, interval: 5 }).toContain('\x1b[3;1R') + + // The paste bytes must come before the reply — no reordering. + expect(combined().indexOf('p')).toBeLessThan(combined().indexOf('\x1b[3;1R')) + expect(combined()).toContain(`${paste}\x1b[3;1R`) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts new file mode 100644 index 00000000000..8d701469775 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' + +// Client-side wire regression for #7329: the daemon's pendingEscapeTailAnsi +// rides the SnapshotStart JSON frame. This drives REAL binary frames through +// the REAL multiplexer (decodeSnapshotInfo → onSnapshot meta) into the REAL +// transport (processData → onReplayData meta). The server-side counterpart is +// terminal-multiplex-escape-tail.test.ts; without this test the client half of +// the chain could silently drop the field and every suite stayed green. + +describe('remote transport snapshot escape-tail threading (#7329)', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.clearAllMocks() + subscriptionCallbacks = null + subscriptionSendBinary.mockReset() + runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } + } + }) + }) + + it('delivers the SnapshotStart pendingEscapeTailAnsi to onReplayData meta', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onReplayData = vi.fn() + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onReplayData } + }) + + // Complete the multiplexer handshake so the terminal stream subscribes. + await expect.poll(() => subscriptionCallbacks !== null, { timeout: 5000 }).toBe(true) + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + + // The client allocates the terminal's stream id in its Subscribe payload. + await expect + .poll(() => subscriptionSendBinary.mock.calls.length, { timeout: 5000 }) + .toBeGreaterThan(0) + const subscribeFrame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0] as Uint8Array)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + expect(subscribeFrame).toBeDefined() + const subscribePayload = decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame!.payload) + const streamId = subscribePayload!.streamId + + // Server → client: initial snapshot whose emulator sat mid-escape. + const frames = [ + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ + cols: 80, + rows: 24, + seq: 7, + source: 'headless', + pendingEscapeTailAnsi: '\x1b[3' + }) + }), + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 0, + payload: encodeTerminalStreamText('user@host:~$ ') + }), + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 0, + payload: new Uint8Array(0) + }) + ] + for (const frame of frames) { + subscriptionCallbacks?.onBinary?.(frame) + } + + await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBeGreaterThan(0) + expect(onReplayData).toHaveBeenCalledWith( + 'user@host:~$ ', + expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[3' }) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index f739ef9707f..9caf6210475 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -391,11 +391,16 @@ export function createRemoteRuntimePtyTransport( outputProcessor.processData(data, storedCallbacks, undefined, meta) } }, - onSnapshot: (data) => { - if (data && isCurrentSubscription()) { + onSnapshot: (data, meta) => { + // Why: a snapshot with no body can still carry a pending mid-escape + // tail that must be replayed so the next live chunk completes it. + if ((data || meta?.pendingEscapeTailAnsi) && isCurrentSubscription()) { outputProcessor.processData(data, storedCallbacks, { replayingBufferedData: true, - suppressAttentionEvents: true + suppressAttentionEvents: true, + ...(meta?.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } + : {}) }) } }, @@ -616,6 +621,49 @@ export function createRemoteRuntimePtyTransport( return inputBatcher.push(data) }, + // Why: terminal query replies (CPR/DSR/DA/OSC color/pixel size) are read by + // the querying program in raw mode with a short timeout. The 8ms input + // debounce makes the reply miss that window, so it lands on the shell prompt + // and is echoed literally / spliced into typed input (#7329). Flush any + // pending batched input first so byte order is preserved, then send the + // reply immediately without arming the debounce timer. + sendInputImmediate(data: string): boolean { + const targetHandle = handle + if (!connected || !targetHandle) { + return false + } + if (!data) { + return true + } + // Why: earlier input (e.g. a large paste) may still be in async byte-length + // validation, so it is captured in the batcher's validationTail and NOT in + // takePending(). Bypassing the queue here would send the reply ahead of it + // and reorder bytes on the wire. In that rare window, route the reply + // through the batcher's ordered queue and flush what is already validated; + // the reply lands right after the pending input once its validation + // resolves. Order correctness beats the immediacy that the debounce + // normally trades away. + if (inputBatcher.hasPendingValidation()) { + const accepted = inputBatcher.push(data) + inputBatcher.flush() + return accepted + } + const pending = inputBatcher.takePending() + const text = `${pending}${data}` + const stream = getCurrentMultiplexedStream(targetHandle) + if (stream?.sendInput(text)) { + return true + } + void callRuntime('terminal.send', { + terminal: targetHandle, + text, + client: { id: clientId, type: 'desktop' } + }).catch((error) => { + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + }) + return true + }, + sendInputAccepted: sendInputAcceptedToRuntime, resize(cols: number, rows: number): boolean { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index bd2f13fc6d2..70fb729b9db 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -37,7 +37,7 @@ type TerminalMultiplexEvent = export type RemoteRuntimeMultiplexedTerminalCallbacks = { onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void - onSnapshot: (data: string) => void + onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void onSubscribed?: () => void onEnd?: () => void onError?: (message: string) => void @@ -86,6 +86,10 @@ type RemoteRuntimeSnapshotInfo = { source?: 'headless' | 'renderer' requestId?: number truncated?: boolean + // Why: a mid-escape tail the emulator could not serialize; the transport + // must write it AFTER the replay reset so the next live chunk completes it + // instead of rendering literally (#7329). + pendingEscapeTailAnsi?: string } type RemoteRuntimeSnapshotRequest = { @@ -97,6 +101,7 @@ type RemoteRuntimeSnapshotRequest = { rows: number seq?: number source?: 'headless' | 'renderer' + pendingEscapeTailAnsi?: string } | null ) => void reject: (error: Error) => void @@ -381,11 +386,14 @@ class RemoteRuntimeTerminalMultiplexer { cols: info?.cols ?? 80, rows: info?.rows ?? 24, seq: info?.seq, - source: info?.source + source: info?.source, + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi }) clearPendingSnapshotRequest(stream) } else if (target === 'initial') { - stream.callbacks.onSnapshot(data ?? '') + stream.callbacks.onSnapshot(data ?? '', { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi + }) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) @@ -608,6 +616,7 @@ function decodeSnapshotInfo( source?: unknown requestId?: unknown truncated?: unknown + pendingEscapeTailAnsi?: unknown }>(payload) if (!raw) { return null @@ -618,7 +627,9 @@ function decodeSnapshotInfo( seq: typeof raw.seq === 'number' ? raw.seq : undefined, source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined, requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, - truncated: raw.truncated === true + truncated: raw.truncated === true, + pendingEscapeTailAnsi: + typeof raw.pendingEscapeTailAnsi === 'string' ? raw.pendingEscapeTailAnsi : undefined } } diff --git a/src/shared/terminal-partial-escape-tail.test.ts b/src/shared/terminal-partial-escape-tail.test.ts new file mode 100644 index 00000000000..e0ef2095ddd --- /dev/null +++ b/src/shared/terminal-partial-escape-tail.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + advancePartialEscapeTail, + extractPartialEscapeTail, + MAX_PARTIAL_ESCAPE_TAIL_LENGTH +} from './terminal-partial-escape-tail' + +describe('extractPartialEscapeTail', () => { + it('returns empty for parser-clean streams', () => { + expect(extractPartialEscapeTail('')).toBe('') + expect(extractPartialEscapeTail('plain text no escapes')).toBe('') + expect(extractPartialEscapeTail('\x1b[38;5;196mred\x1b[0m done')).toBe('') + expect(extractPartialEscapeTail('\x1b[2J\x1b[H')).toBe('') + }) + + it('returns the dangling CSI when a chunk ends mid-sequence', () => { + expect(extractPartialEscapeTail('hello\x1b[3')).toBe('\x1b[3') + expect(extractPartialEscapeTail('a\x1b[38;5;')).toBe('\x1b[38;5;') + expect(extractPartialEscapeTail('\x1b')).toBe('\x1b') + expect(extractPartialEscapeTail('\x1b[')).toBe('\x1b[') + }) + + it('returns the dangling OSC (unterminated) sequence', () => { + // OSC 0 title with no BEL/ST terminator yet. + expect(extractPartialEscapeTail('\x1b]0;my-title')).toBe('\x1b]0;my-title') + // Terminated OSC is clean. + expect(extractPartialEscapeTail('\x1b]0;title\x07after')).toBe('') + expect(extractPartialEscapeTail('\x1b]0;title\x1b\\after')).toBe('') + }) + + it('treats a fresh ESC as aborting a pending CSI', () => { + // The second ESC starts a new (complete) sequence. + expect(extractPartialEscapeTail('\x1b[3\x1b[0m')).toBe('') + // ...and a new dangling one. + expect(extractPartialEscapeTail('\x1b[3\x1b[')).toBe('\x1b[') + }) + + it('treats CAN/SUB as aborting an in-progress escape back to ground', () => { + // CAN (0x18) / SUB (0x1a) abort the sequence in xterm's VT500 parser. + // esc state: + expect(extractPartialEscapeTail('\x1b\x18')).toBe('') // ESC CAN + expect(extractPartialEscapeTail('\x1b\x1a')).toBe('') // ESC SUB + // escIntermediate state (ESC then an intermediate byte, then CAN): + expect(extractPartialEscapeTail('\x1b \x18')).toBe('') // ESC SP CAN + expect(extractPartialEscapeTail('\x1b#\x1a')).toBe('') // ESC # SUB + // csi/osc/string already aborted — keep them green: + expect(extractPartialEscapeTail('\x1b[38;\x18')).toBe('') // CSI ... CAN + expect(extractPartialEscapeTail('\x1b]0;title\x18')).toBe('') // OSC ... CAN + // A CAN that aborts, followed by a fresh dangling sequence, tracks the new one: + expect(extractPartialEscapeTail('\x1b\x18\x1b[3')).toBe('\x1b[3') + }) + + it('is fold-safe across chunk boundaries', () => { + // extract(a + b) === extract(extract(a) + b) — the invariant ingest relies on. + const cases: [string, string][] = [ + ['first\x1b[3', '8;5;196mred'], + ['\x1b', '[0m'], + ['\x1b]0;ti', 'tle\x07'], + ['clean', '\x1b[1'], + // Fold-safety must hold across the CAN abort too. + ['\x1b', '\x18after'], + ['\x1b ', '\x18after'] + ] + for (const [a, b] of cases) { + expect(extractPartialEscapeTail(extractPartialEscapeTail(a) + b)).toBe( + extractPartialEscapeTail(a + b) + ) + } + }) +}) + +describe('advancePartialEscapeTail', () => { + it('accumulates a split sequence across chunks', () => { + let tail = '' + tail = advancePartialEscapeTail(tail, 'ls\r\n\x1b[3') + expect(tail).toBe('\x1b[3') + tail = advancePartialEscapeTail(tail, '8;5;196m') + expect(tail).toBe('') // sequence completed + }) + + it('abandons tracking (returns empty) past the cap', () => { + // An unterminated OSC longer than the cap degrades to pre-fix behavior. + const huge = `\x1b]0;${'x'.repeat(MAX_PARTIAL_ESCAPE_TAIL_LENGTH + 10)}` + expect(advancePartialEscapeTail('', huge)).toBe('') + }) +}) diff --git a/src/shared/terminal-partial-escape-tail.ts b/src/shared/terminal-partial-escape-tail.ts new file mode 100644 index 00000000000..1ee606e60f6 --- /dev/null +++ b/src/shared/terminal-partial-escape-tail.ts @@ -0,0 +1,149 @@ +// Why this module exists: a PTY read can end mid-escape-sequence. The bytes +// already handed to xterm sit inside its parser state machine, not the screen +// buffer, so a serialized snapshot cannot carry them — and the next chunk's +// continuation bytes then render as literal text after a snapshot restore +// (Bug E in notes/garble-fuzz-divergences.md). Tracking the unparsed trailing +// partial sequence at the ingest boundary lets snapshot producers append it +// after the serialized screen so the continuation completes exactly as live. + +// Mirrors the VT500 parser states that can span a chunk boundary. C0 controls +// (except ESC/CAN/SUB) execute mid-sequence without aborting it, matching +// xterm's state machine. +type ScanState = + | 'ground' + | 'esc' + | 'escIntermediate' + | 'csi' + | 'osc' + | 'oscEsc' + | 'string' + | 'stringEsc' + +const ESC = 0x1b +const CAN = 0x18 +const SUB = 0x1a +const BEL = 0x07 + +// Why a cap: OSC/DCS payloads are unbounded and an unterminated one would +// grow the tracked tail (and every snapshot) without limit. Real payloads +// (titles, cwd, hyperlinks) are far below this; beyond it we stop tracking +// and degrade to the pre-fix behavior for that pathological stream. +export const MAX_PARTIAL_ESCAPE_TAIL_LENGTH = 4096 + +/** ESC-state transition shared by the fresh-ESC and abort-reprocess paths. */ +function stateAfterEscByte(code: number): ScanState { + if (code === 0x5b) { + return 'csi' // [ + } + if (code === 0x5d) { + return 'osc' // ] + } + // P / X / ^ / _ open DCS / SOS / PM / APC — ST-terminated strings. + if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) { + return 'string' + } + if (code >= 0x20 && code <= 0x2f) { + return 'escIntermediate' + } + if (code < 0x20 || code === 0x7f) { + return 'esc' // C0 executes / DEL is ignored mid-sequence; ESC via callers + } + return 'ground' // final byte — two-byte sequence (ESC 7, ESC 8, ESC c, …) +} + +/** Returns the trailing incomplete escape sequence of `stream` ('' when the + * stream ends parser-clean). Fold-safe across chunk boundaries: + * extract(a + b) === extract(extract(a) + b), which is how ingest-time + * trackers advance without keeping the whole stream. */ +export function extractPartialEscapeTail(stream: string): string { + let state: ScanState = 'ground' + let start = 0 + for (let i = 0; i < stream.length; i++) { + const code = stream.charCodeAt(i) + if (state === 'ground') { + if (code === ESC) { + start = i + state = 'esc' + } + continue + } + if ( + code === ESC && + state !== 'osc' && + state !== 'string' && + state !== 'oscEsc' && + state !== 'stringEsc' + ) { + // ESC aborts a pending ESC/CSI sequence and starts a new one. + start = i + state = 'esc' + continue + } + // CAN/SUB abort an in-progress escape sequence back to ground in every + // non-string state (xterm's VT500 parser). The csi/osc/string cases handle + // this inline below; esc/escIntermediate must too, or `ESC CAN` and + // `ESC CAN` leave a bogus tail instead of dropping to ground. + if ((code === CAN || code === SUB) && (state === 'esc' || state === 'escIntermediate')) { + state = 'ground' + continue + } + switch (state) { + case 'esc': + state = stateAfterEscByte(code) + break + case 'escIntermediate': + if (code >= 0x30 && code <= 0x7e) { + state = 'ground' + } + // 0x20–0x2f stays; other C0 executes and stays (CAN/SUB handled above). + break + case 'csi': + if (code === CAN || code === SUB) { + state = 'ground' + } else if (code >= 0x40 && code <= 0x7e) { + state = 'ground' // final byte completes the CSI + } + // params/intermediates (0x20–0x3f), C0, DEL stay in-sequence. + break + case 'osc': + if (code === BEL || code === CAN || code === SUB) { + state = 'ground' + } else if (code === ESC) { + state = 'oscEsc' + } + break + case 'oscEsc': + if (code === 0x5c) { + state = 'ground' // ESC \ = ST terminates the OSC + } else { + // The ESC aborted the OSC and opened a new sequence at i-1. + start = i - 1 + state = code === ESC ? 'esc' : stateAfterEscByte(code) + } + break + case 'string': + if (code === CAN || code === SUB) { + state = 'ground' + } else if (code === ESC) { + state = 'stringEsc' + } + break + case 'stringEsc': + if (code === 0x5c) { + state = 'ground' + } else { + start = i - 1 + state = code === ESC ? 'esc' : stateAfterEscByte(code) + } + break + } + } + return state === 'ground' ? '' : stream.slice(start) +} + +/** Ingest-time fold: advance the tracked tail with one more chunk. Returns '' + * (tracking abandoned) when the tail exceeds the cap — see the cap comment. */ +export function advancePartialEscapeTail(pendingTail: string, chunk: string): string { + const tail = extractPartialEscapeTail(pendingTail + chunk) + return tail.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : tail +} diff --git a/src/shared/terminal-query-reply.test.ts b/src/shared/terminal-query-reply.test.ts new file mode 100644 index 00000000000..ee26abf1604 --- /dev/null +++ b/src/shared/terminal-query-reply.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { isTerminalQueryReply } from './terminal-query-reply' + +describe('isTerminalQueryReply', () => { + it('matches synthetic query replies that must be sent immediately', () => { + // CPR cursor position report (answer to CSI 6n) — the #7329 culprit. + expect(isTerminalQueryReply('\x1b[3;1R')).toBe(true) + expect(isTerminalQueryReply('\x1b[22;1R')).toBe(true) + // DSR device status. + expect(isTerminalQueryReply('\x1b[0n')).toBe(true) + // DA1/DA2/DA3 device attributes. + expect(isTerminalQueryReply('\x1b[?1;2c')).toBe(true) + expect(isTerminalQueryReply('\x1b[?61;4c')).toBe(true) + expect(isTerminalQueryReply('\x1b[>0;276;0c')).toBe(true) + // Window/cell pixel-size reports. + expect(isTerminalQueryReply('\x1b[6;16;8t')).toBe(true) + expect(isTerminalQueryReply('\x1b[4;384;640t')).toBe(true) + // DECRPM mode report — private (with ?) and ANSI (without ?). + expect(isTerminalQueryReply('\x1b[?2026;2$y')).toBe(true) + expect(isTerminalQueryReply('\x1b[4;1$y')).toBe(true) + // OSC 10/11 color responses (the #7329 culprit) — BEL and ST terminated. + expect(isTerminalQueryReply('\x1b]11;rgb:2828/2c2c/3434\x1b\\')).toBe(true) + expect(isTerminalQueryReply('\x1b]10;rgb:c0c0/c0c0/c0c0\x07')).toBe(true) + // DECXCPR extended cursor position report (answer to CSI ? 6n). + expect(isTerminalQueryReply('\x1b[?12;5R')).toBe(true) + // Text-area size in characters (answer to CSI 18t). + expect(isTerminalQueryReply('\x1b[8;24;80t')).toBe(true) + // Kitty keyboard flags report (answer to CSI ? u) — crossterm probes this + // at startup, so a debounced reply corrupts the same way CPR did. + expect(isTerminalQueryReply('\x1b[?0u')).toBe(true) + expect(isTerminalQueryReply('\x1b[?31u')).toBe(true) + // DCS DECRQSS reports (vim queries cursor style via DCS $ q) + XTVERSION. + expect(isTerminalQueryReply('\x1bP1$r2 q\x1b\\')).toBe(true) + expect(isTerminalQueryReply('\x1bP1$r0m\x1b\\')).toBe(true) + expect(isTerminalQueryReply('\x1bP0$r\x1b\\')).toBe(true) + expect(isTerminalQueryReply('\x1bP>|xterm.js(5.6.0)\x1b\\')).toBe(true) + }) + + it('documents the accepted modified-F3/CPR collision', () => { + // xterm.js encodes Shift+F3 as CSI 1;2R — byte-identical to a CPR report. + // Classified as a reply on purpose: order is still preserved (the immediate + // path flushes pending input first); see the comment in terminal-query-reply.ts. + expect(isTerminalQueryReply('\x1b[1;2R')).toBe(true) + }) + + it('does NOT match ordinary typed input or navigation sequences', () => { + // Plain text. + expect(isTerminalQueryReply('yes')).toBe(false) + expect(isTerminalQueryReply('y')).toBe(false) + expect(isTerminalQueryReply('\r')).toBe(false) + expect(isTerminalQueryReply('\x03')).toBe(false) // Ctrl-C + // Arrow keys / navigation — must stay batched (coalesced auto-repeat). + expect(isTerminalQueryReply('\x1b[A')).toBe(false) + expect(isTerminalQueryReply('\x1b[B')).toBe(false) + expect(isTerminalQueryReply('\x1b[C')).toBe(false) + expect(isTerminalQueryReply('\x1b[D')).toBe(false) + expect(isTerminalQueryReply('\x1b[H')).toBe(false) // Home + expect(isTerminalQueryReply('\x1b[F')).toBe(false) // End + // Function keys (end in ~). + expect(isTerminalQueryReply('\x1b[15~')).toBe(false) + expect(isTerminalQueryReply('\x1b[3~')).toBe(false) // Delete + // Bare Escape key. + expect(isTerminalQueryReply('\x1b')).toBe(false) + // Alt+key (including Alt+Shift+P, whose bytes prefix the DCS grammar). + expect(isTerminalQueryReply('\x1bb')).toBe(false) + expect(isTerminalQueryReply('\x1bP')).toBe(false) + // Kitty-protocol KEYSTROKES (CSI code;mods u, no "?") must stay batched. + expect(isTerminalQueryReply('\x1b[97;5u')).toBe(false) + expect(isTerminalQueryReply('\x1b[13u')).toBe(false) + // Modified F1/F2/F4 (CSI 1; P/Q/S) are keystrokes, not replies. + expect(isTerminalQueryReply('\x1b[1;2P')).toBe(false) + expect(isTerminalQueryReply('\x1b[1;2Q')).toBe(false) + expect(isTerminalQueryReply('\x1b[1;2S')).toBe(false) + // Bracketed paste markers are input framing, not replies. + expect(isTerminalQueryReply('\x1b[200~')).toBe(false) + expect(isTerminalQueryReply('\x1b[201~')).toBe(false) + // Incomplete / non-terminated OSC and DCS must not match. + expect(isTerminalQueryReply('\x1b]11;rgb:2828/2c2c/3434')).toBe(false) + expect(isTerminalQueryReply('\x1bP1$r2 q')).toBe(false) + }) +}) diff --git a/src/shared/terminal-query-reply.ts b/src/shared/terminal-query-reply.ts new file mode 100644 index 00000000000..4d009fc0d40 --- /dev/null +++ b/src/shared/terminal-query-reply.ts @@ -0,0 +1,69 @@ +// Why this module exists: xterm's public onData stream mixes real keystrokes +// with the parser's synthetic replies to terminal queries a program embedded in +// its output (CPR/DSR cursor + device-status reports, DA device attributes, +// DECRPM mode reports, window/cell pixel-size reports, OSC 10/11 color reports, +// kitty keyboard flag reports, DCS-framed DECRQSS/XTVERSION reports). +// A querying program (e.g. starship/orb) reads these replies synchronously in +// raw mode with a short timeout, so on the remote path they must NOT sit behind +// the input debounce — a late reply lands on the shell prompt in cooked mode, +// which echoes it literally and splices it into the next typed line (#7329). +// This classifier lets the transport send replies immediately while keeping +// ordinary typed input (including bursty arrow-key auto-repeat) coalesced. + +const ESC = String.fromCharCode(0x1b) + +// Built via new RegExp from \u-escaped strings so no literal control +// characters appear in the source. // Final bytes of xterm's own query-reply grammars: +// R — CPR / DECXCPR cursor position report (answer to CSI 6n / CSI ? 6n) +// n — DSR device status report (answer to CSI 5n → CSI 0n) +// c — DA1/DA2/DA3 device attributes (answer to CSI c / CSI > c / CSI = c) +// t — window/cell pixel-size + text-area-size reports (CSI 14t/16t/18t) +// y — DECRPM mode report (answer to CSI ? Ps $ p), body ends "$y" +// u — kitty keyboard flags report (answer to CSI ? u), carries "?" +/* oxlint-disable no-control-regex -- grammars match terminal ESC/BEL sequences by definition */ +// Known accepted collision: xterm.js encodes MODIFIED F3 (Shift/Ctrl/Alt+F3) as +// `CSI 1 ; R`, which is indistinguishable from a CPR report (a classic +// VT ambiguity). Such a keystroke is sent immediately instead of debounced — +// byte order is still preserved (the immediate path flushes pending input +// first), it just skips input-intent/activity bookkeeping. Harmless, so we +// keep the reply grammar complete rather than special-casing it. +const CPR_OR_DSR_RE = new RegExp('^\\u001b\\[\\??[0-9;]*[Rn]$') +const DEVICE_ATTRIBUTES_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') +// 4/6 = pixel-size reports, 8 = text-area size in characters (answer to CSI 18t). +const WINDOW_SIZE_REPORT_RE = new RegExp('^\\u001b\\[[468];[0-9]+;[0-9]+t$') +// `?` optional: private-mode reports carry it (DECRPM), ANSI-mode reports don't. +const DECRPM_RE = new RegExp('^\\u001b\\[\\??[0-9;]*\\$y$') +// Kitty keyboard protocol flags report: CSI ? flags u. The `?` distinguishes it +// from kitty-protocol *keystrokes* (CSI code;mods u), which must stay batched. +const KITTY_FLAGS_RE = new RegExp('^\\u001b\\[\\?[0-9]+u$') +// OSC color/title responses: ESC ] Ps ; body ST (ST = BEL or ESC backslash). +const OSC_RESPONSE_RE = new RegExp('^\\u001b\\][0-9]+;[^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)$') +// DCS-framed reports xterm emits: DECRQSS "ESC P 1 $ r Pt ST" / "ESC P 0 $ r ST" +// (vim queries cursor style this way) and XTVERSION "ESC P > | text ST". +const DCS_RESPONSE_RE = new RegExp('^\\u001bP(?:[01]\\$r[^\\u001b]*|>\\|[^\\u001b]*)\\u001b\\\\$') +/* oxlint-enable no-control-regex */ + +/** + * True when `data` (from xterm.onData) is a synthetic reply the emulator + * generated in response to a query — not something the user typed. These are + * latency-critical and must bypass input coalescing on the remote transport. + * + * Conservative by design: matches only complete, well-formed reply grammars so + * ordinary keystrokes and navigation sequences (arrows CSI A/B/C/D, Home/End, + * function keys ending in ~, kitty CSI-u keystrokes) are never misclassified + * as replies — with the single documented modified-F3/CPR collision above. + */ +export function isTerminalQueryReply(data: string): boolean { + if (data.length < 3 || data[0] !== ESC) { + return false + } + return ( + CPR_OR_DSR_RE.test(data) || + DEVICE_ATTRIBUTES_RE.test(data) || + WINDOW_SIZE_REPORT_RE.test(data) || + DECRPM_RE.test(data) || + KITTY_FLAGS_RE.test(data) || + OSC_RESPONSE_RE.test(data) || + DCS_RESPONSE_RE.test(data) + ) +} From c1d8086a04e60197a6b130b763ec427006eb97c4 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:55:08 +0200 Subject: [PATCH 044/152] fix(omp): migrate legacy overlay state (#7735) * fix(omp): migrate legacy overlay state * fix(omp): harden legacy overlay migration Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/pi/legacy-omp-overlay-migration.ts | 242 +++++++++++++++++ .../pi/titlebar-extension-service.test.ts | 247 ++++++++++++++++++ src/main/pi/titlebar-extension-service.ts | 11 + 3 files changed, 500 insertions(+) create mode 100644 src/main/pi/legacy-omp-overlay-migration.ts diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts new file mode 100644 index 00000000000..88baf78ea9f --- /dev/null +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -0,0 +1,242 @@ +import { cpSync, lstatSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' +import type { Dirent, Stats } from 'node:fs' +import { dirname, join } from 'node:path' +import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from './agent-status-extension-source' +import { ORCA_PI_PREFILL_EXTENSION_FILE } from './prefill-extension-source' +import { ORCA_PI_EXTENSION_FILE } from './titlebar-extension-source' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' + +const LEGACY_PI_OVERLAY_MANIFEST_FILE = '.orca-pi-overlay-manifest.json' +const LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE = '.orca-omp-overlay-migration-complete' +const PI_AGENT_SETTINGS_FILE = 'settings.json' +const SQLITE_DATABASE_EXTENSION = '.db' +const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal'] as const +const MANAGED_EXTENSION_FILES = new Set([ + ORCA_PI_EXTENSION_FILE, + ORCA_PI_PREFILL_EXTENSION_FILE, + ORCA_PI_AGENT_STATUS_EXTENSION_FILE +]) + +type DeferredSidecar = { + baseTargetPath: string + entry: Dirent + nextSegments: string[] +} + +function getPathStats(path: string): Stats | undefined { + try { + return lstatSync(path) + } catch { + return undefined + } +} + +function getSqliteSidecarBaseName(name: string): string | undefined { + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { + if (!name.endsWith(suffix)) { + continue + } + const baseName = name.slice(0, -suffix.length) + return baseName.endsWith(SQLITE_DATABASE_EXTENSION) ? baseName : undefined + } + return undefined +} + +function shouldSkipLegacyOverlayEntry(pathSegments: string[]): boolean { + const name = pathSegments.at(-1) + if (!name) { + return true + } + if (pathSegments.length === 1) { + return ( + name === LEGACY_PI_OVERLAY_MANIFEST_FILE || + name === LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE || + name === PI_AGENT_SETTINGS_FILE + ) + } + return ( + pathSegments.length === 2 && + pathSegments[0] === 'extensions' && + MANAGED_EXTENSION_FILES.has(name) + ) +} + +function copyLegacyFile( + overlayPath: string, + targetPath: string, + copiedFilePaths: Set +): boolean { + if (getPathStats(targetPath)) { + return true + } + try { + mkdirSync(dirname(targetPath), { recursive: true }) + cpSync(overlayPath, targetPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true + }) + copiedFilePaths.add(targetPath) + return true + } catch { + return false + } +} + +function removeCopiedFiles(paths: string[], copiedFilePaths: Set): void { + for (const path of paths) { + if (!copiedFilePaths.delete(path)) { + continue + } + try { + unlinkSync(path) + } catch { + // Best-effort: withholding the marker lets later spawns retry. + } + } +} + +function copyDeferredSidecars( + overlayDir: string, + sourceAgentDir: string, + copiedFilePaths: Set, + deferredSidecars: DeferredSidecar[] +): boolean { + let completed = true + const copiedSidecarsByBase = new Map() + for (const { baseTargetPath, entry, nextSegments } of deferredSidecars) { + if (!copiedFilePaths.has(baseTargetPath)) { + continue + } + + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (stats.isSymbolicLink() || !stats.isFile()) { + continue + } + const wasCopied = copiedFilePaths.has(targetPath) + if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths)) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (!wasCopied && copiedFilePaths.has(targetPath)) { + copiedSidecarsByBase.set(baseTargetPath, [ + ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), + targetPath + ]) + } + } + return completed +} + +function copyMissingLegacyOmpOverlayEntries( + overlayDir: string, + sourceAgentDir: string, + pathSegments: string[] = [], + copiedFilePaths: Set = new Set() +): boolean { + let completed = true + let entries: Dirent[] + try { + entries = readdirSync(overlayDir, { withFileTypes: true }) + } catch { + return false + } + + const deferredSidecars: DeferredSidecar[] = [] + for (const entry of entries) { + const nextSegments = [...pathSegments, entry.name] + if (shouldSkipLegacyOverlayEntry(nextSegments)) { + continue + } + + const sidecarBaseName = getSqliteSidecarBaseName(entry.name) + if (sidecarBaseName) { + deferredSidecars.push({ + baseTargetPath: join(sourceAgentDir, ...pathSegments, sidecarBaseName), + entry, + nextSegments + }) + continue + } + + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + completed = false + continue + } + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + const targetStats = getPathStats(targetPath) + if (targetStats && !isSafeDescendCandidate(targetStats)) { + continue + } + if (!targetStats) { + try { + mkdirSync(targetPath, { recursive: true }) + } catch { + completed = false + continue + } + } + completed = + copyMissingLegacyOmpOverlayEntries( + overlayPath, + sourceAgentDir, + nextSegments, + copiedFilePaths + ) && completed + continue + } + if (!stats.isFile()) { + continue + } + completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths) && completed + } + + return ( + copyDeferredSidecars(overlayDir, sourceAgentDir, copiedFilePaths, deferredSidecars) && completed + ) +} + +export function migrateLegacyOmpOverlayState(sourceAgentDir: string, overlayDir: string): void { + // Why: temporary rescue shim for OMP builds from the legacy overlay window. + // Remove after 2026-08-07 once affected users have had a full upgrade window. + const overlayStats = getPathStats(overlayDir) + if (!overlayStats || overlayStats.isSymbolicLink() || !overlayStats.isDirectory()) { + return + } + const markerPath = join(overlayDir, LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE) + if (getPathStats(markerPath)) { + return + } + try { + mkdirSync(sourceAgentDir, { recursive: true }) + // Why: some pre-managed-extension builds pointed OMP at this overlay, so + // first-login auth/session files can exist only there after an update. + if (copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir)) { + // Why: legacy source overlays can be large and are intentionally kept + // for recovery; mark clean migrations so future OMP spawns stay cheap. + writeFileSync(markerPath, 'complete\n') + } + } catch (error) { + console.warn('[pi-titlebar-extension] failed to migrate legacy OMP overlay state:', error) + } +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 75f01583154..7516f04dad9 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { + chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -11,6 +15,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import type * as osModule from 'node:os' +import type * as fsModule from 'node:fs' import { join } from 'node:path' // The service calls app.getPath('userData') for its overlay root. Point that @@ -50,6 +55,12 @@ function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { return join(userDataDir, rootDir, ptyId) } +function legacySourceOverlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + const hashed = createHash('sha256').update(`source:${sourceAgentDir}`).digest('hex').slice(0, 32) + return join(userDataDir, rootDir, hashed) +} + describe('PiTitlebarExtensionService', () => { let piHome: string @@ -185,6 +196,242 @@ describe('PiTitlebarExtensionService', () => { expect(readFileSync(sourcePath, 'utf-8')).toBe(content) }) + it('migrates missing OMP state from the old source overlay without overwriting source files', () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + mkdirSync(join(overlayDir, 'extensions'), { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'agent.db-wal'), 'legacy sqlite wal') + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + writeFileSync(join(overlayDir, 'auth.json'), 'legacy token should not overwrite') + writeFileSync(join(overlayDir, 'settings.json'), '{"overlayOnly":true}') + writeFileSync(join(overlayDir, '.orca-pi-overlay-manifest.json'), '{}') + writeFileSync(join(overlayDir, 'extensions', 'orca-agent-status.ts'), 'stale managed extension') + writeFileSync(join(overlayDir, 'extensions', 'legacy-user-ext.ts'), 'legacy user extension') + mkdirSync(join(overlayDir, 'extensions', 'legacy-package'), { recursive: true }) + writeFileSync( + join(overlayDir, 'extensions', 'legacy-package', 'orca-prefill.ts'), + 'user package file' + ) + + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-omp-migrate', piHome, 'omp') + + expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + expect(readFileSync(join(piHome, 'auth.json'), 'utf-8')).toBe('secret token') + expect(JSON.parse(readFileSync(join(piHome, 'settings.json'), 'utf-8'))).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + packages: ['npm:pi-web-access'], + terminal: { + showImages: false, + clearOnShrink: false + } + }) + expect(readFileSync(join(piHome, 'extensions', 'legacy-user-ext.ts'), 'utf-8')).toBe( + 'legacy user extension' + ) + expect( + readFileSync(join(piHome, 'extensions', 'legacy-package', 'orca-prefill.ts'), 'utf-8') + ).toBe('user package file') + expect(readFileSync(join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8')).toContain( + '/hook/omp' + ) + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + }) + + it('does not copy stale SQLite sidecars when the target database already exists', () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'agent.db-wal'), 'legacy sqlite wal') + writeFileSync(join(overlayDir, 'agent.db-shm'), 'legacy sqlite shm') + writeFileSync(join(piHome, 'agent.db'), 'fresh sqlite credentials') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-stale-sidecars', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('fresh sqlite credentials') + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-shm'))).toBe(false) + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + }) + + it.skipIf(process.platform === 'win32')( + 'retries a legacy SQLite migration as a whole set after sidecar copy failure', + () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + const walPath = join(overlayDir, 'agent.db-wal') + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(walPath, 'legacy sqlite wal') + chmodSync(walPath, 0o000) + + try { + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-sidecar-fail-1', piHome, 'omp') + + expect(existsSync(join(piHome, 'agent.db'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(overlayDir, '.orca-omp-overlay-migration-complete'))).toBe(false) + + chmodSync(walPath, 0o600) + svc.buildPtyEnv('pty-omp-sidecar-fail-2', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect( + readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8') + ).toBe('complete\n') + } finally { + chmodSync(walPath, 0o600) + } + } + ) + + it('retries a legacy SQLite migration as a whole set after sidecar stat failure', async () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + const walPath = join(overlayDir, 'agent.db-wal') + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(walPath, 'legacy sqlite wal') + + let failNextWalStat = true + vi.resetModules() + vi.doMock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstatSync: (path: Parameters[0]) => { + if (String(path) === walPath && failNextWalStat) { + failNextWalStat = false + throw new Error('transient lstat failure') + } + return actual.lstatSync(path) + } + } + }) + + try { + const { migrateLegacyOmpOverlayState } = await import('./legacy-omp-overlay-migration') + migrateLegacyOmpOverlayState(piHome, overlayDir) + + expect(existsSync(join(piHome, 'agent.db'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(overlayDir, '.orca-omp-overlay-migration-complete'))).toBe(false) + + migrateLegacyOmpOverlayState(piHome, overlayDir) + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + } finally { + vi.doUnmock('node:fs') + vi.resetModules() + } + }) + + it('marks successful legacy OMP migrations so old overlays are not re-scanned', () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-migrate-once-1', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + + writeFileSync(join(overlayDir, 'later-overlay-only-file'), 'should not migrate') + svc.buildPtyEnv('pty-omp-migrate-once-2', piHome, 'omp') + + expect(existsSync(join(piHome, 'later-overlay-only-file'))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'skips special legacy overlay entries while continuing the OMP migration', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + execFileSync('mkfifo', [join(overlayDir, 'stray-fifo')]) + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-special-entry', piHome, 'omp') + + expect(existsSync(join(piHome, 'stray-fifo'))).toBe(false) + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not descend through existing target directory symlinks while migrating OMP state', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + const outsideDir = mkdtempSync(join(tmpdir(), 'orca-omp-target-junction-')) + const sessionsPath = join(piHome, 'sessions') + + try { + symlinkSync(outsideDir, sessionsPath, 'dir') + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-target-dir-symlink', piHome, 'omp') + + expect(lstatSync(sessionsPath).isSymbolicLink()).toBe(true) + expect(existsSync(join(outsideDir, 'legacy-session.jsonl'))).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not follow existing target symlinks while migrating OMP state', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + const outsideDir = mkdtempSync(join(tmpdir(), 'orca-omp-dangling-target-')) + + try { + const outsideTarget = join(outsideDir, 'agent.db') + symlinkSync(outsideTarget, join(piHome, 'agent.db'), 'file') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-target-symlink', piHome, 'omp') + + expect(existsSync(outsideTarget)).toBe(false) + expect(lstatSync(join(piHome, 'agent.db')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + } + ) + it('rebuilding managed extensions for the same ptyId does not corrupt the user Pi dir', () => { const svc = new PiTitlebarExtensionService() svc.buildPtyEnv('pty-3', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index d5b3c0a87c5..37d030b5e31 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -17,6 +17,7 @@ import { isSafeDescendCandidate as sharedIsSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { migrateLegacyOmpOverlayState } from './legacy-omp-overlay-migration' import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's @@ -74,6 +75,12 @@ export class PiTitlebarExtensionService { return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } + private getSourceOverlayDir(sourceAgentDir: string, kind: PiAgentKind): string { + // Why: builds before managed extensions stored Pi/OMP state in source-scoped + // overlays. Resolve the old path so OMP upgrades can rescue stranded state. + return join(this.getOverlayRoot(kind), toSafeOverlayDirName(`source:${sourceAgentDir}`)) + } + private getPtyOverlayDir(ptyId: string, kind: PiAgentKind): string { // Why: old Orca versions used PTY-scoped hashed overlays. Keep resolving // that path so new spawns/teardowns can clean stale pre-migration dirs. @@ -173,6 +180,10 @@ export class PiTitlebarExtensionService { // directory should not prevent the terminal from starting. } + if (kind === 'omp') { + migrateLegacyOmpOverlayState(sourceAgentDir, this.getSourceOverlayDir(sourceAgentDir, 'omp')) + } + const installed = this.installManagedExtensions(sourceAgentDir, kind) const env: Record = {} if (kind === 'omp') { From 9cc177b8ea8b672261f270353403edffb421d817 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:05:24 -0700 Subject: [PATCH 045/152] Update mobile Android APK links to 0.0.24 (#7756) --- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.pt.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index e1403aed2df..6ebc3a03de0 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 22e461eb611..3d1c0469318 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index b2fc4857112..00a409242f2 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 3ba8d351bf9..bb28affe5fc 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Baixar APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d17ae0a8df8..d8aa12b427c 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index 057a20f4968..378aa6018a9 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 6e7513fc973..294babb624c 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From 482bfbc9bb29e98768255d3be7471da415363d2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:10:40 -0700 Subject: [PATCH 046/152] Keep editor tabs in sync with external file changes instead of silently dropping them (#7591) Co-authored-by: Orca --- .../editor/EditorAutosaveController.tsx | 10 +- .../components/editor/EditorContent.test.tsx | 143 ++++++++- .../src/components/editor/EditorContent.tsx | 67 ++++- .../src/components/editor/EditorPanel.tsx | 4 +- .../components/editor/EditorPanelShell.tsx | 6 +- .../editor/ExternalFileChangeBanner.test.tsx | 231 +++++++++++++++ .../editor/ExternalFileChangeBanner.tsx | 185 ++++++++++++ .../ExternalFileChangeCompareDialog.test.tsx | 142 +++++++++ .../ExternalFileChangeCompareDialog.tsx | 191 ++++++++++++ .../editor/diff-content-signature.ts | 16 + .../editor-autosave-conflict-flow.test.ts | 191 ++++++++++++ ...editor-autosave-controller-test-fixture.ts | 52 ++++ .../editor/editor-autosave-controller.ts | 84 +++++- .../src/components/editor/editor-autosave.ts | 13 + .../editor/editor-changed-on-disk-mark.ts | 26 ++ .../editor-external-change-telemetry.ts | 62 ++++ .../editor-restored-tab-conflict-scan.test.ts | 273 ++++++++++++++++++ .../editor-restored-tab-conflict-scan.ts | 186 ++++++++++++ .../editor/editor-self-write-registry.test.ts | 14 +- .../editor/editor-self-write-registry.ts | 10 +- .../useEditorPanelContentState.test.tsx | 129 ++++++++- .../editor/useEditorPanelContentState.ts | 44 ++- .../useEditorPanelExternalContentEvents.ts | 6 + .../useEditorPanelFileLoadRetry.test.tsx | 2 +- .../src/components/tab-bar/EditorFileTab.tsx | 11 +- .../src/hooks/useEditorExternalWatch.test.ts | 173 +++++++++++ .../src/hooks/useEditorExternalWatch.ts | 125 +++++++- src/renderer/src/i18n/locales/en.json | 18 ++ src/renderer/src/i18n/locales/es.json | 18 ++ src/renderer/src/i18n/locales/ja.json | 18 ++ src/renderer/src/i18n/locales/ko.json | 18 ++ src/renderer/src/i18n/locales/zh.json | 18 ++ .../workspace-session-editor-drafts.test.ts | 45 +++ src/renderer/src/lib/workspace-session.ts | 8 +- src/renderer/src/store/slices/editor.ts | 56 +++- src/shared/telemetry-events.ts | 22 ++ src/shared/types.ts | 3 + src/shared/workspace-session-schema.ts | 3 +- 38 files changed, 2570 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx create mode 100644 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx create mode 100644 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx create mode 100644 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx create mode 100644 src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts create mode 100644 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts create mode 100644 src/renderer/src/components/editor/editor-changed-on-disk-mark.ts create mode 100644 src/renderer/src/components/editor/editor-external-change-telemetry.ts create mode 100644 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts create mode 100644 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts diff --git a/src/renderer/src/components/editor/EditorAutosaveController.tsx b/src/renderer/src/components/editor/EditorAutosaveController.tsx index 0ba4358d7e8..d9655616695 100644 --- a/src/renderer/src/components/editor/EditorAutosaveController.tsx +++ b/src/renderer/src/components/editor/EditorAutosaveController.tsx @@ -1,13 +1,21 @@ import { useEffect } from 'react' import { useAppStore } from '@/store' import { attachEditorAutosaveController } from './editor-autosave-controller' +import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan' export default function EditorAutosaveController(): null { useEffect(() => { // Why: autosave and quit coordination need to survive editor tab switches, // but keeping the full EditorPanel mounted while hidden widened the restart // surface too far. Keep only this narrow controller alive between mounts. - return attachEditorAutosaveController(useAppStore) + const detachAutosave = attachEditorAutosaveController(useAppStore) + // Why: restored dirty tabs must be conflict-checked app-level, before any + // panel mounts — autosave can otherwise write over an offline agent edit. + const detachConflictScan = attachRestoredTabConflictScan(useAppStore) + return () => { + detachAutosave() + detachConflictScan() + } }, []) return null diff --git a/src/renderer/src/components/editor/EditorContent.test.tsx b/src/renderer/src/components/editor/EditorContent.test.tsx index d1507dd2a26..130c92abe35 100644 --- a/src/renderer/src/components/editor/EditorContent.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.test.tsx @@ -1,6 +1,14 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' + +// Why: EditorContent's mode renderers (Monaco, DiffViewer, ...) are lazy(); +// renderToStaticMarkup cannot resolve them. Stubbing them keeps the banner +// branch structure renderable so its placement is pinned by tests. +vi.mock('@/lib/lazy-with-retry', () => ({ + lazyWithRetry: () => () => null +})) + import { EditorContent, getMarkdownSourceLineOffset } from './EditorContent' function createOpenFile(overrides: Partial = {}): OpenFile { @@ -67,7 +75,7 @@ describe('EditorContent', () => { handleDirtyStateHint={vi.fn()} handleSave={vi.fn()} handleSaveForFile={vi.fn()} - reloadFileContent={vi.fn()} + reloadContent={vi.fn()} /> ) @@ -75,4 +83,137 @@ describe('EditorContent', () => { expect(html).toContain('Access denied') expect(html).not.toContain('Unable to render notebook') }) + + it('shows the changed-on-disk banner above a dirty edit tab', () => { + const activeFile = createOpenFile({ + id: '/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + language: 'typescript', + isDirty: true, + externalMutation: 'changed' + }) + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('role="alert"') + expect(html).toContain('changed on disk') + expect(html).toContain('Reload from Disk') + }) + + it('shows the changed-on-disk banner above a dirty unstaged diff without collapsing it', () => { + const activeFile = createOpenFile({ + id: 'diff:/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + language: 'typescript', + mode: 'diff', + diffSource: 'unstaged', + isDirty: true, + externalMutation: 'changed' + }) + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('role="alert"') + expect(html).toContain('changed on disk') + // Why: the diff-mode wrapper must give the viewer its height back — a + // flex-1-only wrapper collapsed the DiffViewer to 0px (found live). + expect(html).toContain('flex h-full min-h-0 flex-col') + }) + + it('shows the changed-on-disk banner on a dirty markdown diff in preview mode', () => { + const activeFile = createOpenFile({ + id: 'diff:/repo/notes.md', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged', + isDirty: true, + externalMutation: 'changed' + }) + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('role="alert"') + expect(html).toContain('changed on disk') + expect(html).toContain('Previewing the modified version of this diff') + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index ed813e0ecf3..76996c0ac16 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -33,6 +33,7 @@ import { import { getDiffContentSignature } from './diff-content-signature' import { translate } from '@/i18n/i18n' import { CheckRunDetailsPanel } from './CheckRunDetailsPanel' +import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' const MonacoEditor = lazy(() => import('./MonacoEditor')) const DiffViewer = lazy(() => import('./DiffViewer')) @@ -139,7 +140,7 @@ export function EditorContent({ handleDirtyStateHint, handleSave, handleSaveForFile, - reloadFileContent + reloadContent }: { activeFile: OpenFile viewStateScopeId: string @@ -166,7 +167,7 @@ export function EditorContent({ handleDirtyStateHint: (dirty: boolean) => void handleSave: (content: string) => Promise handleSaveForFile: (file: OpenFile, content: string) => Promise - reloadFileContent: (file: OpenFile) => void + reloadContent: (file: OpenFile) => void }): React.JSX.Element { const editorViewStateKey = viewStateScopeId === activeFile.id @@ -505,10 +506,7 @@ export function EditorContent({ if (fc.loadError) { return (
- reloadFileContent(contentFile)} - /> + reloadContent(contentFile)} />
) } @@ -709,9 +707,7 @@ export function EditorContent({ ) } if (fc.loadError) { - return ( - reloadFileContent(activeFile)} /> - ) + return reloadContent(activeFile)} /> } if (fc.isBinary) { return ( @@ -758,9 +754,7 @@ export function EditorContent({ ) } if (fc.loadError) { - return ( - reloadFileContent(activeFile)} /> - ) + return reloadContent(activeFile)} /> } if (fc.isBinary) { if (fc.isImage) { @@ -777,8 +771,16 @@ export function EditorContent({
) } + const externalChangeBanner = + activeFile.externalMutation === 'changed' ? ( + + ) : null if (isChangesMode) { - return ( + const changesView = ( ) + if (!externalChangeBanner) { + return changesView + } + return ( +
+ {externalChangeBanner} +
{changesView}
+
+ ) } return (
+ {externalChangeBanner} {activeFile.conflict && ( + ) : null if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) { return (
+ {diffExternalChangeBanner}
{/* Why: a rendered markdown preview cannot express additions and deletions simultaneously, so preview mode intentionally shows the @@ -927,7 +950,7 @@ export function EditorContent({ const diffReloadNonce = activeFile.diffContentReloadNonce ?? 0 const originalModelKey = `${diffViewStateKey}:original:${getDiffContentSignature(dc.originalContent)}` const modifiedModelKey = `${diffViewStateKey}:modified:${getDiffContentSignature(dc.modifiedContent)}:${diffReloadNonce}` - return ( + const diffViewer = ( ) + // Why: editable unstaged diffs can hold unsaved edits, so they get the same + // changed-on-disk recovery banner as edit tabs; its reload refetches the + // diff body rather than plain file content. + if (activeFile.externalMutation !== 'changed') { + return diffViewer + } + return ( + // Why: h-full (not flex-1) — the diff-mode container is not a flex parent, + // so flex-1 resolves to zero height and collapses this wrapper. The inner + // div must itself be a flex column because DiffViewer's root sizes with + // flex-1 and collapses to 0px inside a block parent. +
+ {diffExternalChangeBanner} +
{diffViewer}
+
+ ) } // Why: a minimal read-only banner that shows the raw front-matter content diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 8af1758a170..03fde8425c5 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -92,7 +92,7 @@ function EditorPanelInner({ activeFile.mode === 'edit' && canUseChangesModeForFile(activeFile) && editorViewMode[activeFile.id] === 'changes' - const { fileContents, diffContents, reloadFileContent } = useEditorPanelContentState({ + const { fileContents, diffContents, reloadContent } = useEditorPanelContentState({ activeFile, isChangesMode: requestedChangesMode, openFiles, @@ -377,7 +377,7 @@ function EditorPanelInner({ onDirtyStateHint={handleDirtyStateHint} onSave={handleSave} onSaveForFile={handleSaveForFile} - onReloadFileContent={reloadFileContent} + onReloadContent={reloadContent} onCloseMarkdownTableOfContents={() => setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) } diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index 89d2a2a8afd..de63cd78b2d 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -46,7 +46,7 @@ type EditorPanelShellProps = { onDirtyStateHint: (dirty: boolean) => void onSave: (content: string) => Promise onSaveForFile: (file: OpenFile, content: string) => Promise - onReloadFileContent: (file: OpenFile) => void + onReloadContent: (file: OpenFile) => void onCloseMarkdownTableOfContents: () => void onCloseRenameDialog: () => void onRenameConfirm: (newRelPath: string) => Promise @@ -86,7 +86,7 @@ export function EditorPanelShell({ onDirtyStateHint, onSave, onSaveForFile, - onReloadFileContent, + onReloadContent, onCloseMarkdownTableOfContents, onCloseRenameDialog, onRenameConfirm, @@ -152,7 +152,7 @@ export function EditorPanelShell({ handleDirtyStateHint={onDirtyStateHint} handleSave={onSave} handleSaveForFile={onSaveForFile} - reloadFileContent={onReloadFileContent} + reloadContent={onReloadContent} showMarkdownTableOfContents={showMarkdownTableOfContents} showMarkdownFrontmatter={markdownFrontmatterVisible} onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents} diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx new file mode 100644 index 00000000000..eae2909bef5 --- /dev/null +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx @@ -0,0 +1,231 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' + +const toastMock = vi.hoisted(() => vi.fn()) +const readRuntimeFileContentMock = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ + toast: toastMock +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: vi.fn() + } +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + readRuntimeFileContent: readRuntimeFileContentMock +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + settingsForRuntimeOwner: () => null +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: () => undefined +})) + +import { + ExternalFileChangeBanner, + keepTabEditsOverExternalChange, + reloadTabContentFromDisk +} from './ExternalFileChangeBanner' +import { getDiskBaselineSignature } from './diff-content-signature' +import { useAppStore } from '@/store' + +const file = { + id: 'file-1', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + mode: 'edit', + isDirty: true, + externalMutation: 'changed' +} as OpenFile + +describe('ExternalFileChangeBanner', () => { + const clearEditorDraft = vi.fn() + const markFileDirty = vi.fn() + const setExternalMutation = vi.fn() + const setEditorDraft = vi.fn() + const setLastKnownDiskSignature = vi.fn() + + function mockStoreState(editorDrafts: Record, openFiles: OpenFile[] = [file]) { + vi.mocked(useAppStore.getState).mockReturnValue({ + clearEditorDraft, + markFileDirty, + setExternalMutation, + setEditorDraft, + setLastKnownDiskSignature, + editorDrafts, + openFiles, + settings: {} + } as never) + } + + beforeEach(() => { + vi.clearAllMocks() + readRuntimeFileContentMock.mockResolvedValue({ content: 'disk content', isBinary: false }) + mockStoreState({}) + }) + + it('renders the overwrite warning, all three actions, and an alert role', () => { + const html = renderToStaticMarkup( + + ) + expect(html).toContain('role="alert"') + expect(html).toContain('changed on disk') + expect(html).toContain('Saving will overwrite') + expect(html).toContain('Compare') + expect(html).toContain('Reload from Disk') + expect(html).toContain('Keep My Edits') + }) + + it('reload clears the draft, dirty flag, and mark before refetching content', () => { + mockStoreState({ 'file-1': 'unsaved text' }) + const calls: string[] = [] + clearEditorDraft.mockImplementation(() => calls.push('clearEditorDraft')) + markFileDirty.mockImplementation(() => calls.push('markFileDirty')) + setExternalMutation.mockImplementation(() => calls.push('setExternalMutation')) + const reloadContent = vi.fn(() => calls.push('reloadContent')) + + reloadTabContentFromDisk(file, reloadContent) + + expect(clearEditorDraft).toHaveBeenCalledWith('file-1') + expect(markFileDirty).toHaveBeenCalledWith('file-1', false) + expect(setExternalMutation).toHaveBeenCalledWith('file-1', null) + expect(reloadContent).toHaveBeenCalledWith(file) + // Why: the draft shadows loaded content (editBuffers ?? fileContents), so + // the refetch must come last or the stale unsaved text stays visible. + expect(calls).toEqual([ + 'clearEditorDraft', + 'markFileDirty', + 'setExternalMutation', + 'reloadContent' + ]) + }) + + it('reload offers an undo toast that restores the discarded draft and the conflict', () => { + mockStoreState({ 'file-1': 'discarded draft' }) + + reloadTabContentFromDisk(file, vi.fn()) + + expect(toastMock).toHaveBeenCalledTimes(1) + const options = toastMock.mock.calls[0][1] as { + action: { label: string; onClick: () => void } + } + vi.clearAllMocks() + // Why: after a real reload the tab is clean with no draft — the undo + // guard only restores over that untouched state. + mockStoreState({}, [{ ...file, isDirty: false, externalMutation: undefined } as OpenFile]) + + options.action.onClick() + + expect(setEditorDraft).toHaveBeenCalledWith('file-1', 'discarded draft') + expect(markFileDirty).toHaveBeenCalledWith('file-1', true) + // Why: disk still differs from the restored draft — the conflict (and its + // autosave suspension) must return with the edits. + expect(setExternalMutation).toHaveBeenCalledWith('file-1', 'changed') + }) + + it('undo is a no-op when the tab closed while the toast was up', () => { + mockStoreState({ 'file-1': 'discarded draft' }) + + reloadTabContentFromDisk(file, vi.fn()) + + const options = toastMock.mock.calls[0][1] as { + action: { label: string; onClick: () => void } + } + vi.clearAllMocks() + mockStoreState({}, []) + + options.action.onClick() + + expect(setEditorDraft).not.toHaveBeenCalled() + expect(markFileDirty).not.toHaveBeenCalled() + expect(setExternalMutation).not.toHaveBeenCalled() + }) + + it('undo is a no-op when the user typed or saved after the reload', () => { + mockStoreState({ 'file-1': 'discarded draft' }) + + reloadTabContentFromDisk(file, vi.fn()) + + const options = toastMock.mock.calls[0][1] as { + action: { label: string; onClick: () => void } + } + vi.clearAllMocks() + // Why: post-reload edits are newer intent than the discarded draft — + // undoing over them would be a second silent discard. + mockStoreState({ 'file-1': 'newer post-reload edits' }, [ + { ...file, isDirty: true, externalMutation: undefined } as OpenFile + ]) + + options.action.onClick() + + expect(setEditorDraft).not.toHaveBeenCalled() + expect(setExternalMutation).not.toHaveBeenCalled() + }) + + it('does not toast when there was no draft to restore', () => { + mockStoreState({}) + + reloadTabContentFromDisk(file, vi.fn()) + + expect(toastMock).not.toHaveBeenCalled() + }) + + it('undo restores the pre-reload disk signature with the draft', async () => { + // Why: the reload re-stamps the baseline to the new disk content; without + // restoring the old signature the restart scan sees disk == baseline and + // silently drops the conflict the undo just brought back. + mockStoreState({ 'file-1': 'discarded draft' }, [ + { ...file, lastKnownDiskSignature: 'pre-reload-signature' } as OpenFile + ]) + + reloadTabContentFromDisk(file, vi.fn()) + + const options = toastMock.mock.calls[0][1] as { + action: { label: string; onClick: () => void } + } + vi.clearAllMocks() + mockStoreState({}, [{ ...file, isDirty: false, externalMutation: undefined } as OpenFile]) + + options.action.onClick() + + expect(setLastKnownDiskSignature).toHaveBeenCalledWith('file-1', 'pre-reload-signature') + }) + + it('keep-my-edits clears the mark without touching the draft or dirty flag', () => { + keepTabEditsOverExternalChange(file) + + expect(setExternalMutation).toHaveBeenCalledWith('file-1', null) + expect(clearEditorDraft).not.toHaveBeenCalled() + expect(markFileDirty).not.toHaveBeenCalled() + }) + + it('keep-my-edits advances the disk baseline so the dismissal survives restart', async () => { + readRuntimeFileContentMock.mockResolvedValue({ content: 'agent content', isBinary: false }) + mockStoreState({}, [{ ...file, externalMutation: undefined } as OpenFile]) + + keepTabEditsOverExternalChange(file) + await Promise.resolve() + await Promise.resolve() + + expect(setLastKnownDiskSignature).toHaveBeenCalledWith( + 'file-1', + getDiskBaselineSignature('agent content') + ) + }) + + it('keep-my-edits does not stamp a baseline over a newer conflict', async () => { + // Why: if the file changed again before the read resolved, the fresh + // 'changed' mark owns the baseline — stamping would hide that conflict + // from the restart scan. + readRuntimeFileContentMock.mockResolvedValue({ content: 'even newer', isBinary: false }) + mockStoreState({}, [{ ...file, externalMutation: 'changed' } as OpenFile]) + + keepTabEditsOverExternalChange(file) + await Promise.resolve() + await Promise.resolve() + + expect(setLastKnownDiskSignature).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx new file mode 100644 index 00000000000..d23421605fc --- /dev/null +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { readRuntimeFileContent } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { OpenFile } from '@/store/slices/editor' +import { ExternalFileChangeCompareDialog } from './ExternalFileChangeCompareDialog' +import { getDiskBaselineSignature } from './diff-content-signature' +import { trackExternalChangeConflictAction } from './editor-external-change-telemetry' + +// Why: when an external process (usually an agent) rewrites a file while the +// tab holds unsaved edits, the reload pipeline preserves the buffer and marks +// the tab externalMutation='changed' (issue #7265). This banner is the +// recovery path — without it the tab is silently stale until close/reopen and +// the next save clobbers the newer disk content unannounced. + +const RELOAD_UNDO_TOAST_DURATION_MS = 8_000 + +export function reloadTabContentFromDisk( + file: OpenFile, + reloadContent: (file: OpenFile) => void +): void { + const state = useAppStore.getState() + const discardedDraft = state.editorDrafts[file.id] + const discardedDiskSignature = state.openFiles.find( + (openFile) => openFile.id === file.id + )?.lastKnownDiskSignature + // Why: drop the draft before reloading — the buffer shadows loaded content + // (editBuffers ?? fileContents), so a reload alone would keep showing the + // stale unsaved text. + state.clearEditorDraft(file.id) + state.markFileDirty(file.id, false) + state.setExternalMutation(file.id, null) + reloadContent(file) + if (discardedDraft === undefined) { + return + } + // Why: on diff tabs the reload rotates the Monaco model, destroying the undo + // stack — without this toast a mistaken click is an unrecoverable discard. + toast( + translate('auto.components.editor.ExternalFileChangeBanner.5c02de9b31', 'Reloaded from disk'), + { + description: file.relativePath, + duration: RELOAD_UNDO_TOAST_DURATION_MS, + action: { + label: translate('auto.components.editor.ExternalFileChangeBanner.d1e830fa22', 'Undo'), + onClick: () => { + const current = useAppStore.getState() + const liveFile = current.openFiles.find((openFile) => openFile.id === file.id) + // Why: the tab may have closed while the toast was up; restoring a + // draft for a dead fileId would strand an orphan buffer. And if the + // user already typed after the reload (dirty), that newer work wins + // — undoing over it would be a second silent discard. isDirty is the + // signal: the editor content-sync repopulates editorDrafts with the + // reloaded content itself, so draft existence proves nothing. + if (!liveFile || liveFile.isDirty) { + return + } + current.setEditorDraft(file.id, discardedDraft) + current.markFileDirty(file.id, true) + // Why: the disk still differs from the restored draft, so the + // conflict (and its autosave suspension) must come back with it. + current.setExternalMutation(file.id, 'changed') + if (discardedDiskSignature !== undefined) { + // Why: the reload re-stamped the baseline to the new disk content; + // restoring the pre-reload signature with the draft keeps the + // restart scan re-deriving the conflict the undo just brought back. + current.setLastKnownDiskSignature(file.id, discardedDiskSignature) + } + trackExternalChangeConflictAction(file, 'undo_reload') + } + } + } + ) +} + +export function keepTabEditsOverExternalChange(file: OpenFile): void { + const state = useAppStore.getState() + state.setExternalMutation(file.id, null) + // Why: the dismissal must survive restart — without advancing the baseline + // to the current disk content, the restored-tab conflict scan re-derives + // the dismissed conflict from the stale signature on every launch. + // Best-effort: a failed read leaves the old signature, which can only + // re-surface the banner — never lose data. + void readRuntimeFileContent({ + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + }) + .then((result) => { + if (result.isBinary) { + return + } + const current = useAppStore.getState() + const liveFile = current.openFiles.find((openFile) => openFile.id === file.id) + // Why: only stamp while the dismissal still stands — a save or a newer + // conflict marked in the interim owns the baseline. + if (!liveFile || liveFile.externalMutation === 'changed') { + return + } + current.setLastKnownDiskSignature(file.id, getDiskBaselineSignature(result.content)) + }) + .catch(() => undefined) +} + +export function ExternalFileChangeBanner({ + file, + currentContent, + reloadContent +}: { + file: OpenFile + /** The tab's live buffer (draft if dirty) — what "Keep My Edits" keeps. */ + currentContent: string + /** Refetches the tab's content — file body for edit tabs, diff body for + * unstaged diff tabs. */ + reloadContent: (file: OpenFile) => void +}): React.JSX.Element { + const [compareOpen, setCompareOpen] = useState(false) + + const handleReload = (): void => { + trackExternalChangeConflictAction(file, 'reload') + reloadTabContentFromDisk(file, reloadContent) + } + const handleKeepEdits = (): void => { + trackExternalChangeConflictAction(file, 'keep') + keepTabEditsOverExternalChange(file) + } + const handleCompare = (): void => { + trackExternalChangeConflictAction(file, 'compare') + setCompareOpen(true) + } + + return ( + // Why: role=alert because the banner appears asynchronously (an agent + // rewrote the file) — screen readers must announce it unprompted. +
+
+
+ + {/* Why: wraps instead of truncating — the overwrite warning at the + end of the sentence is the part the user must not lose. */} + + {translate( + 'auto.components.editor.ExternalFileChangeBanner.7c41e90d12', + 'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.' + )} + +
+
+ + + +
+
+ {compareOpen && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx new file mode 100644 index 00000000000..2a906005555 --- /dev/null +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + getConnectionIdForFile: vi.fn(), + getState: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + readRuntimeFileContent: mocks.readRuntimeFileContent +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + settingsForRuntimeOwner: () => null +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: mocks.getConnectionIdForFile +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState } +})) +// Why: the lazy DiffViewer chunk cannot resolve under happy-dom; a stub that +// echoes its props pins the disk-left/buffer-right wiring instead. +vi.mock('@/lib/lazy-with-retry', () => ({ + lazyWithRetry: () => (props: { originalContent: string; modifiedContent: string }) => ( +
+ original:{props.originalContent}|modified:{props.modifiedContent} +
+ ) +})) + +import { ExternalFileChangeCompareDialog } from './ExternalFileChangeCompareDialog' + +const file = { + id: 'file-1', + filePath: '/repo/notes.ts', + relativePath: 'notes.ts', + worktreeId: 'wt-1', + mode: 'edit', + isDirty: true, + externalMutation: 'changed' +} as OpenFile + +describe('ExternalFileChangeCompareDialog', () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeEach(() => { + mocks.readRuntimeFileContent.mockReset() + mocks.getConnectionIdForFile.mockReset() + mocks.getConnectionIdForFile.mockReturnValue(undefined) + mocks.getState.mockReturnValue({ settings: null }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + container?.remove() + document.body.innerHTML = '' + }) + + async function render(element: React.JSX.Element): Promise { + await act(async () => { + root = createRoot(container!) + root.render(element) + }) + } + + it('shows the disk version left and the buffer right once the read resolves', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk version', isBinary: false }) + + await render( + + ) + + expect(document.body.textContent).toContain('File changed on disk') + expect(document.body.textContent).toContain('original:disk version|modified:buffer version') + expect(document.body.textContent).toContain('Reload from Disk') + expect(document.body.textContent).toContain('Keep My Edits') + }) + + it('surfaces a read failure instead of a blank comparison', async () => { + mocks.readRuntimeFileContent.mockRejectedValue(new Error('transport down')) + + await render( + + ) + + expect(document.body.textContent).toContain('Could not read the file from disk') + expect(document.body.textContent).toContain('transport down') + }) + + it('wires the footer actions and closes the dialog around them', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk version', isBinary: false }) + const onReload = vi.fn() + const onKeepEdits = vi.fn() + const onOpenChange = vi.fn() + + await render( + + ) + + const buttons = Array.from(document.body.querySelectorAll('button')) + await act(async () => { + buttons.find((b) => b.textContent === 'Reload from Disk')?.click() + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onReload).toHaveBeenCalledTimes(1) + + await act(async () => { + buttons.find((b) => b.textContent === 'Keep My Edits')?.click() + }) + expect(onKeepEdits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx new file mode 100644 index 00000000000..eb6611abeb9 --- /dev/null +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx @@ -0,0 +1,191 @@ +import React, { Suspense, useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { detectLanguage } from '@/lib/language-detect' +import { readRuntimeFileContent } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { translate } from '@/i18n/i18n' + +const DiffViewer = lazy(() => import('./DiffViewer')) + +type DiskReadState = + | { kind: 'loading' } + | { kind: 'error'; message: string } + | { kind: 'binary' } + | { kind: 'ready'; content: string } + +// Why: choosing between "Reload from Disk" and "Keep My Edits" blind is the +// sharpest edge of the changed-on-disk banner — this dialog shows exactly +// what each choice discards before the user commits (issue #7265 follow-up). +export function ExternalFileChangeCompareDialog({ + file, + currentContent, + open, + onOpenChange, + onReload, + onKeepEdits +}: { + file: OpenFile + /** The tab's live buffer — the unsaved edits the user would keep. */ + currentContent: string + open: boolean + onOpenChange: (open: boolean) => void + onReload: () => void + onKeepEdits: () => void +}): React.JSX.Element { + const [diskState, setDiskState] = useState({ kind: 'loading' }) + + useEffect(() => { + if (!open) { + return + } + let cancelled = false + setDiskState({ kind: 'loading' }) + // Why: read at open time — the banner can be minutes old and the agent + // may have written again since; the comparison must show current disk. + void readRuntimeFileContent({ + settings: settingsForRuntimeOwner(useAppStore.getState().settings, file.runtimeEnvironmentId), + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + }) + .then((result) => { + if (cancelled) { + return + } + setDiskState( + result.isBinary ? { kind: 'binary' } : { kind: 'ready', content: result.content } + ) + }) + .catch((err: unknown) => { + if (cancelled) { + return + } + setDiskState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err) + }) + }) + return () => { + cancelled = true + } + }, [open, file.filePath, file.relativePath, file.worktreeId, file.runtimeEnvironmentId]) + + const language = detectLanguage(file.relativePath) + + return ( + + + + + {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.4b8de20a11', + 'File changed on disk' + )} + + + {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.90cc31e4d7', + 'Disk version on the left, your unsaved edits on the right.' + )} + + +
+ {diskState.kind === 'loading' ? ( +
+ + {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.8fe30ab254', + 'Reading file from disk...' + )} +
+ ) : diskState.kind === 'error' ? ( +
+ {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.e2b1cd0393', + 'Could not read the file from disk: {{value0}}', + { value0: diskState.message } + )} +
+ ) : diskState.kind === 'binary' ? ( +
+ {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.b6cf20d514', + 'The file on disk is binary — no text comparison available.' + )} +
+ ) : ( + + + {translate( + 'auto.components.editor.ExternalFileChangeCompareDialog.2c8f1e07b9', + 'Loading comparison...' + )} +
+ } + > +
+ +
+ + )} +
+ + + + + + + ) +} diff --git a/src/renderer/src/components/editor/diff-content-signature.ts b/src/renderer/src/components/editor/diff-content-signature.ts index 927d9711d89..ba5bc26fc4b 100644 --- a/src/renderer/src/components/editor/diff-content-signature.ts +++ b/src/renderer/src/components/editor/diff-content-signature.ts @@ -9,3 +9,19 @@ export function getDiffContentSignature(content: string): string { } return (hash >>> 0).toString(16) } + +// Why: the disk baseline guards against silently overwriting external writes +// (issue #7265), where a hash collision means a missed conflict — so it gets +// two independent FNV lanes plus the length instead of the single 32-bit lane +// that suffices for cosmetic model rotation above. +export function getDiskBaselineSignature(content: string): string { + let hashA = 2166136261 + let hashB = 84696351 + for (let i = 0; i < content.length; i += 1) { + const code = content.charCodeAt(i) + hashA ^= code + hashA = Math.imul(hashA, 16777619) + hashB = Math.imul(hashB ^ code, 1099511627) + } + return `${(hashA >>> 0).toString(16)}-${(hashB >>> 0).toString(16)}-${content.length.toString(16)}` +} diff --git a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts new file mode 100644 index 00000000000..753836b65f6 --- /dev/null +++ b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts @@ -0,0 +1,191 @@ +// Why: the changed-on-disk conflict flow (issue #7265) — draft preservation, +// echo-aware backstop marking, autosave suspension, and baseline settling — +// in the headless autosave controller. Split from +// editor-autosave-controller.test.ts to stay under max-lines. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, requestEditorFileSave } from './editor-autosave' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { __clearSelfWriteRegistryForTests, recordSelfWrite } from './editor-self-write-registry' +import { createEditorStore, stubEditorWindow } from './editor-autosave-controller-test-fixture' + +const mocks = vi.hoisted(() => ({ + getConnectionIdForFile: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: mocks.getConnectionIdForFile +})) + +function openDirtyFile(store: ReturnType, draft = 'unsaved edit'): void { + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/repo/file.ts', draft) + store.getState().markFileDirty('/repo/file.ts', true) +} + +function dispatchExternalChange(): void { + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, { + detail: { worktreeId: 'wt-1', worktreePath: '/repo', relativePath: 'file.ts' } + }) + ) +} + +describe('editor autosave changed-on-disk conflict flow', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.getConnectionIdForFile.mockReset() + mocks.getConnectionIdForFile.mockReturnValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + __clearSelfWriteRegistryForTests() + }) + + it('preserves dirty drafts and marks the tab changed-on-disk on external file change', () => { + stubEditorWindow() + const store = createEditorStore() + openDirtyFile(store) + + const cleanup = attachEditorAutosaveController(store) + try { + dispatchExternalChange() + + const file = store.getState().openFiles[0] + expect(file?.isDirty).toBe(true) + expect(file?.externalMutation).toBe('changed') + expect(store.getState().editorDrafts['/repo/file.ts']).toBe('unsaved edit') + } finally { + cleanup() + } + }) + + it('clears drafts and a stale changed-on-disk mark for clean tabs on external file change', () => { + stubEditorWindow() + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setExternalMutation('/repo/file.ts', 'changed') + + const cleanup = attachEditorAutosaveController(store) + try { + dispatchExternalChange() + + const file = store.getState().openFiles[0] + expect(file?.isDirty).toBe(false) + expect(file?.externalMutation).toBeUndefined() + } finally { + cleanup() + } + }) + + it('does not backstop-mark a dirty tab for the echo of its own save', () => { + stubEditorWindow() + const store = createEditorStore() + openDirtyFile(store, 'typed during save') + // Why: the combined-Changes reload notification routes through the + // controller for the saved path — a fresh self-write stamp means the + // event is Orca's own echo, not an external change. + recordSelfWrite('/repo/file.ts', 'orca save') + + const cleanup = attachEditorAutosaveController(store) + try { + dispatchExternalChange() + + const file = store.getState().openFiles[0] + expect(file?.externalMutation).toBeUndefined() + expect(file?.isDirty).toBe(true) + expect(store.getState().editorDrafts['/repo/file.ts']).toBe('typed during save') + } finally { + cleanup() + } + }) + + it('suspends autosave while a tab is marked changed-on-disk and resumes when cleared', async () => { + const writeFile = stubEditorWindow() + const store = createEditorStore() + openDirtyFile(store, 'user edit') + store.getState().setExternalMutation('/repo/file.ts', 'changed') + + const cleanup = attachEditorAutosaveController(store) + try { + await vi.advanceTimersByTimeAsync(1500) + expect(writeFile).not.toHaveBeenCalled() + + // Keep My Edits clears the mark — autosave resumes and overwrites. + store.getState().setExternalMutation('/repo/file.ts', null) + await vi.advanceTimersByTimeAsync(1500) + expect(writeFile).toHaveBeenCalledWith({ + filePath: '/repo/file.ts', + content: 'user edit' + }) + } finally { + cleanup() + } + }) + + it('suspends autosave while a restored tab awaits disk baseline verification', async () => { + const writeFile = stubEditorWindow() + const store = createEditorStore() + openDirtyFile(store, 'restored draft') + // Why: mimic hydration — the scan has not yet compared disk against the + // persisted baseline, so autosave must hold off (a slow remote read must + // not lose a race to this timer). + store.setState({ + openFiles: store + .getState() + .openFiles.map((f) => + f.id === '/repo/file.ts' ? { ...f, pendingDiskBaselineVerification: true } : f + ) + } as never) + + const cleanup = attachEditorAutosaveController(store) + try { + await vi.advanceTimersByTimeAsync(1500) + expect(writeFile).not.toHaveBeenCalled() + + store.getState().clearPendingDiskBaselineVerification('/repo/file.ts') + await vi.advanceTimersByTimeAsync(1500) + expect(writeFile).toHaveBeenCalledWith({ + filePath: '/repo/file.ts', + content: 'restored draft' + }) + } finally { + cleanup() + } + }) + + it('clears the changed-on-disk mark after a successful save', async () => { + const writeFile = stubEditorWindow() + const store = createEditorStore() + openDirtyFile(store, 'user version') + store.getState().setExternalMutation('/repo/file.ts', 'changed') + + const cleanup = attachEditorAutosaveController(store) + try { + await requestEditorFileSave({ fileId: '/repo/file.ts' }) + + expect(writeFile).toHaveBeenCalledWith({ + filePath: '/repo/file.ts', + content: 'user version' + }) + const file = store.getState().openFiles[0] + expect(file?.isDirty).toBe(false) + expect(file?.externalMutation).toBeUndefined() + } finally { + cleanup() + } + }) +}) diff --git a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts new file mode 100644 index 00000000000..6ecf884091a --- /dev/null +++ b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts @@ -0,0 +1,52 @@ +// Why: shared rig for autosave-controller suites — the controller needs a +// real editor store slice plus a window stub (event target, timers, fs +// bridge), and duplicating that per test file bloats suites past max-lines. +import { vi } from 'vitest' +import { createStore, type StoreApi } from 'zustand/vanilla' +import { createEditorSlice } from '@/store/slices/editor' +import type { AppState } from '@/store' + +export type EditorWindowStub = { + addEventListener: Window['addEventListener'] + removeEventListener: Window['removeEventListener'] + dispatchEvent: Window['dispatchEvent'] + setTimeout: Window['setTimeout'] + clearTimeout: Window['clearTimeout'] + api: { + fs: { + writeFile: ReturnType + } + } +} + +/** Stubs the global window with an isolated event target and fs bridge; + * returns the writeFile mock for assertions. */ +export function stubEditorWindow(): ReturnType { + const writeFile = vi.fn().mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { + writeFile + } + } + } satisfies EditorWindowStub) + return writeFile +} + +export function createEditorStore(): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + settings: { + editorAutoSave: true, + editorAutoSaveDelayMs: 1000 + }, + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi +} diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 23de3fecdef..7c6f043bd8a 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -16,6 +16,7 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, + isAutosaveSuspendedForFile, normalizeAutoSaveDelayMs, ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, ORCA_EDITOR_FILE_SAVED_EVENT, @@ -27,8 +28,16 @@ import { type EditorSaveFileDetail, type EditorSaveQuiesceDetail } from './editor-autosave' +import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' import { flushPendingEditorChange } from './editor-pending-flush' -import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry' +import { + clearSelfWrite, + hasRecentSelfWrite, + recordSelfWrite, + SELF_WRITE_REMOTE_TTL_MS +} from './editor-self-write-registry' +import { getDiskBaselineSignature } from './diff-content-signature' +import { trackExternalChangeConflictAction } from './editor-external-change-telemetry' import { autosaveSubscriberInputsEqual, getAutosaveSubscriberInputs, @@ -62,7 +71,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { saveGeneration.set(fileId, (saveGeneration.get(fileId) ?? 0) + 1) } - const queueSave = (file: OpenFile, fallbackContent: string): Promise => { + const queueSave = ( + file: OpenFile, + fallbackContent: string, + trigger: 'autosave' | 'user' = 'user' + ): Promise => { clearAutoSaveTimer(file.id) const queuedGeneration = saveGeneration.get(file.id) ?? 0 @@ -80,6 +93,12 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + // Why: explicit user saves proceed even while suspended (the banner + // warned) and clear both suspension flags below. + if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { + return + } + const contentToSave = state.editorDrafts[file.id] ?? fallbackContent const connectionId = getConnectionIdForFile(liveFile.worktreeId, liveFile.filePath) ?? undefined @@ -91,7 +110,14 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { // round-tripping back into a setContent that jumps the cursor to the // end (and, under round-trip drift, can drop keystrokes typed in the // debounce window). See editor-self-write-registry. - recordSelfWrite(liveFile.filePath, contentToSave, liveFile.runtimeEnvironmentId) + recordSelfWrite( + liveFile.filePath, + contentToSave, + liveFile.runtimeEnvironmentId, + connectionId || liveFile.runtimeEnvironmentId?.trim() + ? SELF_WRITE_REMOTE_TTL_MS + : undefined + ) try { await writeRuntimeFile( { @@ -122,6 +148,21 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { if (!stillDirty) { nextState.clearEditorDraft(file.id) } + // Why: disk now holds contentToSave — future edits baseline on it, and + // a restore must not flag our own save as an external change. An + // explicit save also settles any pending baseline verification: the + // user chose to write, so there is nothing left to verify against. + nextState.setLastKnownDiskSignature(file.id, getDiskBaselineSignature(contentToSave)) + nextState.clearPendingDiskBaselineVerification(file.id) + // Why: the write just made disk match the buffer, resolving any + // changed-on-disk conflict in favor of the user's content. The banner + // warned before this point; keeping the mark would show a stale + // conflict for a file that no longer diverges. + const savedFile = nextState.openFiles.find((openFile) => openFile.id === file.id) + if (savedFile?.externalMutation === 'changed') { + trackExternalChangeConflictAction(savedFile, 'save_overwrite') + nextState.setExternalMutation(file.id, null) + } window.dispatchEvent( new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, { @@ -171,6 +212,9 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { file && file.isDirty && canAutoSaveOpenFile(file) && + // Why: suspension holds until the user picks a side via the banner + // (or saves manually) — see the queueSave guard. + !isAutosaveSuspendedForFile(file) && draft !== undefined if (!shouldKeepTimer) { clearAutoSaveTimer(fileId) @@ -184,7 +228,12 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { const autoSaveDelayMs = normalizeAutoSaveDelayMs(state.settings.editorAutoSaveDelayMs) for (const file of state.openFiles) { const draft = state.editorDrafts[file.id] - if (!file.isDirty || draft === undefined || !canAutoSaveOpenFile(file)) { + if ( + !file.isDirty || + draft === undefined || + !canAutoSaveOpenFile(file) || + isAutosaveSuspendedForFile(file) + ) { clearAutoSaveTimer(file.id) continue } @@ -198,7 +247,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { const timerId = window.setTimeout(() => { autoSaveTimers.delete(file.id) autoSaveScheduledContent.delete(file.id) - void queueSave(file, draft) + void queueSave(file, draft, 'autosave') }, autoSaveDelayMs) autoSaveTimers.set(file.id, timerId) } @@ -377,12 +426,35 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + // Why: dirty files must keep their draft — destroying unsaved edits on an + // external write is the data-loss half of issue #7265. Mark them + // changed-on-disk instead (backstop for tabs that became dirty during the + // notify debounce; the watch hook marks the ones dirty at event time). + const reloadingFiles = matchingFiles.filter((file) => !file.isDirty) for (const file of matchingFiles) { + if (file.isDirty) { + // Why: the self-write check keeps this backstop from marking on the + // echo of Orca's own save (the combined-Changes reload notification + // routes through here and would otherwise bypass the watch hook's + // echo verification). + if (!hasRecentSelfWrite(file.filePath, file.runtimeEnvironmentId)) { + markFileChangedOnDisk(state, file, { + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + origin: 'live' + }) + } + continue + } clearAutoSaveTimer(file.id) bumpSaveGeneration(file.id) state.markFileDirty(file.id, false) + // Why: this file is about to reload fresh disk content, so a stale + // changed-on-disk mark (set while it was dirty) is resolved. + if (file.externalMutation === 'changed') { + state.setExternalMutation(file.id, null) + } } - state.clearEditorDrafts(matchingFiles.map((file) => file.id)) + state.clearEditorDrafts(reloadingFiles.map((file) => file.id)) } // Why: the root store subscriber fires for every terminal title/focus tick. diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 09d0ae86663..c36f91e8a9b 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -77,6 +77,16 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean { return file.mode === 'edit' || (file.mode === 'diff' && file.diffSource === 'unstaged') } +// Why: autosave must not resolve a changed-on-disk conflict by overwriting +// the newer external content, nor write over a restored tab whose disk +// baseline is still unverified (the conflict may simply not be marked YET). +// One predicate so the save-queue gate and the timer scheduler cannot drift. +export function isAutosaveSuspendedForFile( + file: Pick +): boolean { + return file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true +} + export function normalizeAutoSaveDelayMs(value: unknown): number { // Why: settings are persisted locally and can be missing or hand-edited. // Clamp the delay at the write site so autosave never degenerates into an @@ -176,6 +186,9 @@ export function requestEditorFileClose(fileId: string): void { ) } +// CONTRACT: this event fires even when some tabs of the path are dirty — +// every consumer MUST skip dirty files per-file. Reloading a dirty tab's +// content destroys its unsaved draft (the data-loss half of issue #7265). export function notifyEditorExternalFileChange(target: EditorPathMutationTarget): void { window.dispatchEvent( new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, { diff --git a/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts b/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts new file mode 100644 index 00000000000..06e726bd2ee --- /dev/null +++ b/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts @@ -0,0 +1,26 @@ +import type { OpenFile } from '@/store/slices/editor' +import { canAutoSaveOpenFile } from './editor-autosave' +import { trackExternalChangeConflictShown } from './editor-external-change-telemetry' + +type ChangedOnDiskMarkState = { + setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void +} + +// Why: the changed-on-disk mark has one rule — dirty, banner-capable tab, +// telemetry only on the first surfacing — but three writers (the watch hook +// at fs-event time, the autosave controller's notification backstop, and the +// restored-tab conflict scan). Centralizing keeps the rule from drifting; +// each writer keeps its own echo/eligibility guard at the call site. +export function markFileChangedOnDisk( + state: ChangedOnDiskMarkState, + file: OpenFile, + options: { connectionId: string | undefined; origin: 'live' | 'restore' } +): void { + if (!file.isDirty || !canAutoSaveOpenFile(file)) { + return + } + if (file.externalMutation !== 'changed') { + trackExternalChangeConflictShown(file, options) + } + state.setExternalMutation(file.id, 'changed') +} diff --git a/src/renderer/src/components/editor/editor-external-change-telemetry.ts b/src/renderer/src/components/editor/editor-external-change-telemetry.ts new file mode 100644 index 00000000000..f9752147cfd --- /dev/null +++ b/src/renderer/src/components/editor/editor-external-change-telemetry.ts @@ -0,0 +1,62 @@ +// Why: one place derives the path-free analytics shape for the changed-on-disk +// conflict flow (issue #7265), so the three marking sites and the banner +// actions cannot drift on enum values. Measures false-banner rates per +// transport and which resolution users actually pick. +import { track } from '@/lib/telemetry' +import { getConnectionIdForFile } from '@/lib/connection-context' +import type { OpenFile } from '@/store/slices/editor' + +type ConflictSurface = 'edit' | 'unstaged-diff' +type ConflictTransport = 'local' | 'ssh' | 'runtime' + +export type ExternalChangeConflictAction = + | 'reload' + | 'keep' + | 'compare' + | 'undo_reload' + | 'save_overwrite' + +function conflictSurface(file: Pick): ConflictSurface { + return file.mode === 'edit' ? 'edit' : 'unstaged-diff' +} + +export function conflictTransport( + connectionId: string | undefined, + runtimeEnvironmentId: string | null | undefined +): ConflictTransport { + if (connectionId) { + return 'ssh' + } + if (runtimeEnvironmentId?.trim()) { + return 'runtime' + } + return 'local' +} + +export function trackExternalChangeConflictShown( + file: Pick, + options: { connectionId: string | undefined; origin: 'live' | 'restore' } +): void { + track('editor_external_change_conflict_shown', { + surface: conflictSurface(file), + transport: conflictTransport(options.connectionId, file.runtimeEnvironmentId), + origin: options.origin + }) +} + +export function trackExternalChangeConflictAction( + file: Pick, + action: ExternalChangeConflictAction +): void { + track('editor_external_change_conflict_action', { + action, + surface: conflictSurface(file), + // Why: shown-vs-action cross-tabs per transport are the point of the + // metric — false-banner detection needs to see WHICH transports' banners + // users dismiss versus act on. + transport: conflictTransport( + getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + file.runtimeEnvironmentId + ) + }) +} 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 new file mode 100644 index 00000000000..8c50887a090 --- /dev/null +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStore, type StoreApi } from 'zustand/vanilla' +import { createEditorSlice } from '@/store/slices/editor' +import type { AppState } from '@/store' +import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan' +import { getDiskBaselineSignature } from './diff-content-signature' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + getConnectionIdForFile: vi.fn(), + pathExists: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + readRuntimeFileContent: mocks.readRuntimeFileContent +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + settingsForRuntimeOwner: () => null +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: mocks.getConnectionIdForFile +})) + +function createEditorStore(): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => ({ + settings: {}, + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi +} + +function openRestoredDirtyTab( + store: StoreApi, + filePath: string, + baselineContent: string +): void { + store.getState().openFile({ + filePath, + relativePath: filePath.slice(1), + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft(filePath, 'restored draft') + store.getState().markFileDirty(filePath, true) + store.getState().setLastKnownDiskSignature(filePath, getDiskBaselineSignature(baselineContent)) + // Why: hydration flags restored dirty tabs for verification; tests mimic it. + store.setState({ + openFiles: store + .getState() + .openFiles.map((f) => + f.id === filePath ? { ...f, pendingDiskBaselineVerification: true } : f + ) + } as never) +} + +describe('attachRestoredTabConflictScan', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.readRuntimeFileContent.mockReset() + mocks.getConnectionIdForFile.mockReset() + mocks.getConnectionIdForFile.mockReturnValue(undefined) + mocks.pathExists.mockReset() + mocks.pathExists.mockResolvedValue(true) + vi.stubGlobal('window', { api: { fs: { pathExists: mocks.pathExists } } }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('marks a restored dirty tab whose file changed while the app was closed', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'agent rewrote this offline', + isBinary: false + }) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(store.getState().openFiles[0]?.externalMutation).toBe('changed') + } finally { + detach() + } + }) + + it('leaves a restored dirty tab unmarked when disk still matches its baseline', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'original baseline', + isBinary: false + }) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined() + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) + } finally { + detach() + } + }) + + it('does not read files for clean tabs or tabs without a baseline', async () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/clean.ts', + relativePath: 'clean.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().openFile({ + filePath: '/repo/dirty-no-baseline.ts', + relativePath: 'dirty-no-baseline.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/repo/dirty-no-baseline.ts', 'draft') + store.getState().markFileDirty('/repo/dirty-no-baseline.ts', true) + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + } finally { + detach() + } + }) + + it('retries a failed read and marks once the file becomes readable', async () => { + // Why: SSH/runtime connections come up after launch; the first reads fail. + mocks.readRuntimeFileContent + .mockRejectedValueOnce(new Error('connection not ready')) + .mockResolvedValue({ content: 'agent rewrote this offline', isBinary: false }) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined() + await vi.advanceTimersByTimeAsync(2_100) + expect(store.getState().openFiles[0]?.externalMutation).toBe('changed') + } finally { + detach() + } + }) + + it('clears the pending-verification flag on both verification outcomes', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'original baseline', + isBinary: false + }) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/match.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + // Why: the flag suspends autosave — leaving it set after a clean + // verification would strand the tab's autosave forever. + expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined() + + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'agent rewrote this offline', + isBinary: false + }) + openRestoredDirtyTab(store, '/repo/mismatch.ts', 'original baseline') + await vi.advanceTimersByTimeAsync(10) + const mismatchTab = store.getState().openFiles.find((f) => f.id === '/repo/mismatch.ts') + expect(mismatchTab?.externalMutation).toBe('changed') + expect(mismatchTab?.pendingDiskBaselineVerification).toBeUndefined() + } finally { + detach() + } + }) + + it('does not re-verify live dirty tabs that were never flagged at hydration', async () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/live.ts', + relativePath: 'live.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/repo/live.ts', 'live edits') + store.getState().markFileDirty('/repo/live.ts', true) + store.getState().setLastKnownDiskSignature('/repo/live.ts', getDiskBaselineSignature('base')) + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + // Why: in-session drift is the live watcher's job; re-reading here would + // turn the scan into a poller and skew the 'restore' telemetry origin. + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + } finally { + detach() + } + }) + + it('resolves verification with a deleted mark when the file is definitively gone', async () => { + // Why: a file deleted while the app was closed can never verify — without + // a terminal state the retry loop keeps the tab's autosave silently + // suspended for the whole session. + mocks.readRuntimeFileContent.mockRejectedValue(new Error('ENOENT')) + mocks.pathExists.mockResolvedValue(false) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/deleted.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + const tab = store.getState().openFiles[0] + expect(tab?.pendingDiskBaselineVerification).toBeUndefined() + expect(tab?.externalMutation).toBe('deleted') + // Why: the verification is resolved — no further retries may fire. + await vi.advanceTimersByTimeAsync(60_000) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) + } finally { + detach() + } + }) + + it('keeps retrying with the suspension intact when the existence probe fails', async () => { + // Why: a transport that is down cannot disprove existence — lifting the + // suspension unverified would reopen the clobber window the moment the + // transport recovers. + mocks.readRuntimeFileContent.mockRejectedValue(new Error('connection not ready')) + mocks.pathExists.mockRejectedValue(new Error('connection not ready')) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/unreachable.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + await vi.advanceTimersByTimeAsync(2_100) + const tab = store.getState().openFiles[0] + expect(tab?.pendingDiskBaselineVerification).toBe(true) + expect(tab?.externalMutation).toBeUndefined() + expect(mocks.readRuntimeFileContent.mock.calls.length).toBeGreaterThan(1) + } 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( + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + store.getState().markFileDirty('/repo/file.ts', false) + resolveRead({ content: 'agent rewrote this offline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + expect(store.getState().openFiles[0]?.externalMutation).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 new file mode 100644 index 00000000000..bc4029321f4 --- /dev/null +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -0,0 +1,186 @@ +// Why: a dirty tab restored from a workspace session carries edits based on +// disk content that may have changed while the app was closed (an agent write, +// a sync tool). The in-memory changed-on-disk mark does not survive restarts, +// so without this scan a resumed autosave would silently overwrite that newer +// content (issue #7265 follow-up). The scan re-derives the conflict from +// ground truth: it reads each restored dirty tab's file and compares the disk +// signature against the persisted edit baseline. Autosave is hard-suspended +// for those tabs (pendingDiskBaselineVerification, set at hydration) until a +// verification resolves — otherwise the read would merely race the autosave +// timer and a slow remote read would lose. +import type { StoreApi } from 'zustand' +import type { AppState } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { readRuntimeFileContent } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { canAutoSaveOpenFile } from './editor-autosave' +import { getDiskBaselineSignature } from './diff-content-signature' +import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' + +type AppStoreApi = Pick, 'getState' | 'subscribe'> + +// Why: SSH/runtime reads fail while the connection is still coming up after +// launch. Retry fast for the first minute, then keep probing slowly — +// giving up on a transport error would either strand the tab's autosave +// suspension or lift it unverified right as the transport comes back up. +// Only a definitive not-found (file deleted while the app was closed) ends +// the loop early; see probeFileMissing. +const VERIFY_RETRY_MS = 2_000 +const VERIFY_SLOW_RETRY_MS = 15_000 +const VERIFY_FAST_ATTEMPTS = 30 + +export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { + // Why: dedupes 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>() + let disposed = false + + // Why: distinguishes "file was deleted while the app was closed" (a + // definitive not-found) from a transport still coming up. Only local/SSH + // paths can be probed — for runtime-owned files window.api.fs would stat + // the client-local path and misreport a remote file as gone. + const probeFileMissing = async (file: OpenFile): Promise => { + const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId) + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return false + } + try { + const exists = await globalThis.window?.api?.fs?.pathExists?.({ + filePath: file.filePath, + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + }) + return exists === false + } catch { + // Why: a failed probe can't disprove existence — keep retrying. + return false + } + } + + const verify = async (file: OpenFile): Promise => { + // Why: OpenFile ids are file paths — a marker left behind by an early + // exit would silently skip every future verification of a reopened + // same-path tab. Only a scheduled retry may keep the marker set. + let retryScheduled = false + try { + const state = store.getState() + const result = await readRuntimeFileContent({ + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + }) + if (disposed) { + return + } + const liveFile = store.getState().openFiles.find((f) => f.id === file.id) + if (!liveFile) { + return + } + // Why: verification resolved — lift the autosave suspension regardless + // of outcome. If a save raced the read, the save already re-baselined + // and cleared the flag itself; wasPending distinguishes that case. + const wasPending = liveFile.pendingDiskBaselineVerification === true + store.getState().clearPendingDiskBaselineVerification(file.id) + if ( + !wasPending || + result.isBinary || + !liveFile.isDirty || + liveFile.externalMutation === 'changed' + ) { + return + } + if (getDiskBaselineSignature(result.content) !== file.lastKnownDiskSignature) { + markFileChangedOnDisk(store.getState(), liveFile, { + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + origin: 'restore' + }) + } + } catch { + if (disposed) { + return + } + if (await probeFileMissing(file)) { + if (disposed) { + return + } + // Why: a definitive not-found IS ground truth — there is no newer + // disk content for a save to clobber, so verification is resolved. + // Converge to the live delete affordance (tombstone mark) instead of + // retrying forever with the tab's autosave silently suspended. + const liveFile = store.getState().openFiles.find((f) => f.id === file.id) + if (!liveFile) { + return + } + const wasPending = liveFile.pendingDiskBaselineVerification === true + store.getState().clearPendingDiskBaselineVerification(file.id) + if (wasPending && liveFile.isDirty && liveFile.externalMutation !== 'changed') { + store.getState().setExternalMutation(file.id, 'deleted') + } + return + } + if (disposed) { + return + } + const attempts = (attemptsByFileId.get(file.id) ?? 0) + 1 + attemptsByFileId.set(file.id, attempts) + retryScheduled = true + const timer = setTimeout( + () => { + retryTimers.delete(timer) + inFlightFileIds.delete(file.id) + scan() + }, + attempts < VERIFY_FAST_ATTEMPTS ? VERIFY_RETRY_MS : VERIFY_SLOW_RETRY_MS + ) + retryTimers.add(timer) + } finally { + if (!retryScheduled) { + inFlightFileIds.delete(file.id) + } + } + } + + const scan = (): void => { + if (disposed) { + return + } + for (const file of store.getState().openFiles) { + if ( + !file.pendingDiskBaselineVerification || + !file.isDirty || + !file.lastKnownDiskSignature || + file.externalMutation === 'changed' || + !canAutoSaveOpenFile(file) || + inFlightFileIds.has(file.id) + ) { + continue + } + inFlightFileIds.add(file.id) + void verify(file) + } + } + + let previousOpenFiles = store.getState().openFiles + const unsubscribe = store.subscribe(() => { + const nextOpenFiles = store.getState().openFiles + if (nextOpenFiles === previousOpenFiles) { + return + } + previousOpenFiles = nextOpenFiles + scan() + }) + scan() + + return () => { + disposed = true + unsubscribe() + for (const timer of retryTimers) { + clearTimeout(timer) + } + retryTimers.clear() + } +} diff --git a/src/renderer/src/components/editor/editor-self-write-registry.test.ts b/src/renderer/src/components/editor/editor-self-write-registry.test.ts index d05a98a4054..e2eacaca95e 100644 --- a/src/renderer/src/components/editor/editor-self-write-registry.test.ts +++ b/src/renderer/src/components/editor/editor-self-write-registry.test.ts @@ -4,7 +4,8 @@ import { __getSelfWriteRegistrySizeForTests, clearSelfWrite, hasRecentSelfWrite, - recordSelfWrite + recordSelfWrite, + SELF_WRITE_REMOTE_TTL_MS } from './editor-self-write-registry' describe('editor self-write registry', () => { @@ -77,4 +78,15 @@ describe('editor self-write registry', () => { expect(hasRecentSelfWrite('/repo/0.md')).toBe(false) expect(hasRecentSelfWrite('/repo/259.md')).toBe(true) }) + + it('keeps remote-TTL stamps alive past the local window', () => { + // Why: SSH/runtime watcher echoes can land seconds after the write; the + // longer TTL keeps them recognized as Orca's own save. + recordSelfWrite('/repo/remote.md', 'content', 'env-1', SELF_WRITE_REMOTE_TTL_MS) + + vi.advanceTimersByTime(751) + expect(hasRecentSelfWrite('/repo/remote.md', 'env-1')).toBe(true) + vi.advanceTimersByTime(SELF_WRITE_REMOTE_TTL_MS) + expect(hasRecentSelfWrite('/repo/remote.md', 'env-1')).toBe(false) + }) }) diff --git a/src/renderer/src/components/editor/editor-self-write-registry.ts b/src/renderer/src/components/editor/editor-self-write-registry.ts index 561f4ec6116..0bf0b115079 100644 --- a/src/renderer/src/components/editor/editor-self-write-registry.ts +++ b/src/renderer/src/components/editor/editor-self-write-registry.ts @@ -12,6 +12,11 @@ import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/f // path, bounded by a short TTL so a genuinely external edit that lands after // the window still gets picked up. const SELF_WRITE_TTL_MS = 750 +// Why: SSH/runtime watcher echoes travel a poll-plus-network path and can +// land seconds after the write. A local-sized TTL lets the echo arrive after +// the stamp expired, which raises a false changed-on-disk banner on remote +// tabs while typing with autosave on. +export const SELF_WRITE_REMOTE_TTL_MS = 3000 const SELF_WRITE_MAX_STAMPS = 256 export type RecentSelfWrite = { @@ -49,7 +54,8 @@ function enforceSelfWriteStampLimit(): void { export function recordSelfWrite( absolutePath: string, content?: string, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + ttlMs: number = SELF_WRITE_TTL_MS ): void { const now = Date.now() pruneExpiredSelfWrites(now) @@ -59,7 +65,7 @@ export function recordSelfWrite( stamps.delete(key) stamps.set(key, { content: content ?? null, - expiresAt: now + SELF_WRITE_TTL_MS + expiresAt: now + ttlMs }) enforceSelfWriteStampLimit() } diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 8a932eb3668..15cd29adb44 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -47,6 +47,7 @@ vi.mock('@/store', () => ({ })) import { useEditorPanelContentState } from './useEditorPanelContentState' +import { getDiskBaselineSignature } from './diff-content-signature' import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave' type Deferred = { @@ -87,6 +88,7 @@ type ProbeProps = { let latestFileContents: Record = {} let latestDiffContents: Record = {} +let latestReloadContent: (file: OpenFile) => void = () => {} const EMPTY_GIT_STATUS_BY_WORKTREE: Record = {} function HookProbe({ @@ -103,6 +105,7 @@ function HookProbe({ }) latestFileContents = state.fileContents latestDiffContents = state.diffContents + latestReloadContent = state.reloadContent return null } @@ -135,7 +138,11 @@ describe('useEditorPanelContentState', () => { mocks.isWorktreeConnectionResolved.mockReset() mocks.isWorktreeConnectionResolved.mockReturnValue(true) mocks.getState.mockReset() - mocks.getState.mockReturnValue({ settings: null }) + mocks.getState.mockReturnValue({ + settings: null, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) }) afterEach(() => { @@ -564,4 +571,124 @@ describe('useEditorPanelContentState', () => { }) expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('fresh diff content') }) + + it('routes reloadContent for a diff tab to a forced diff refetch, not a file read', async () => { + // Why: the changed-on-disk banner's "Reload from Disk" on an unstaged + // diff tab must refetch the diff body — routing it to the file store + // would leave the visible diff stale (and vice versa for edit tabs). + const activeFile = createOpenFile({ + id: 'wt-1::diff::unstaged::file.ts', + mode: 'diff', + diffSource: 'unstaged' + }) + mocks.getRuntimeGitDiff + .mockResolvedValueOnce({ + kind: 'text', + originalContent: 'old', + modifiedContent: 'first diff content', + originalIsBinary: false, + modifiedIsBinary: false + }) + .mockResolvedValueOnce({ + kind: 'text', + originalContent: 'old', + modifiedContent: 'reloaded diff content', + originalIsBinary: false, + modifiedIsBinary: false + }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + await vi.waitFor(() => + expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('first diff content') + ) + + await act(async () => { + latestReloadContent(activeFile) + }) + + await vi.waitFor(() => + expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('reloaded diff content') + ) + expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + }) + + it('routes reloadContent for an edit tab to a forced file read, not a diff refetch', async () => { + const activeFile = createOpenFile() + mocks.readRuntimeFileContent + .mockResolvedValueOnce({ content: 'old content', isBinary: false }) + .mockResolvedValueOnce({ content: 'reloaded content', isBinary: false }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('old content')) + + await act(async () => { + latestReloadContent(activeFile) + }) + + await vi.waitFor(() => + expect(latestFileContents[activeFile.id]?.content).toBe('reloaded content') + ) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2) + expect(mocks.getRuntimeGitDiff).not.toHaveBeenCalled() + }) + + it('stamps the disk baseline when a clean tab load resolves', async () => { + const activeFile = createOpenFile() + const setLastKnownDiskSignature = vi.fn() + mocks.getState.mockReturnValue({ + settings: null, + openFiles: [activeFile], + setLastKnownDiskSignature + }) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk content', isBinary: false }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => + expect(setLastKnownDiskSignature).toHaveBeenCalledWith( + activeFile.id, + getDiskBaselineSignature('disk content') + ) + ) + }) + + it('keeps a dirty tab baseline untouched by content loads', async () => { + // Why: a dirty tab's draft still derives from the OLD content — moving + // the baseline on load would hide the conflict its restore check exists + // to catch. + const activeFile = createOpenFile({ isDirty: true }) + const setLastKnownDiskSignature = vi.fn() + mocks.getState.mockReturnValue({ + settings: null, + openFiles: [activeFile], + setLastKnownDiskSignature + }) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk content', isBinary: false }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('disk content')) + expect(setLastKnownDiskSignature).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 152b8974f50..ae05de95475 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -10,6 +10,7 @@ import { } from '@/lib/connection-context' import { joinPath } from '@/lib/path' import { useAppStore } from '@/store' +import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { @@ -51,7 +52,26 @@ type UseEditorPanelContentStateParams = { type UseEditorPanelContentStateResult = { fileContents: Record diffContents: Record - reloadFileContent: (file: OpenFile) => void + reloadContent: (file: OpenFile) => void +} + +// Why: a clean load re-baselines what this tab's future edits are based on; a +// dirty tab keeps its baseline (its draft still derives from the older content +// the signature was taken over). Best-effort metadata — a failure here must +// not convert an already-delivered load into an error view, hence the guard. +function stampCleanTabDiskBaseline(id: string, result: FileContent): void { + if (result.isBinary || result.loadError) { + return + } + try { + const state = useAppStore.getState() + const loadedFile = state.openFiles.find((file) => file.id === id) + if (loadedFile && !loadedFile.isDirty) { + state.setLastKnownDiskSignature(id, getDiskBaselineSignature(result.content)) + } + } catch (err) { + console.warn('[editor] failed to stamp disk baseline', err) + } } function inFlightReadKey(connectionId: string | undefined, filePath: string): string { @@ -171,6 +191,7 @@ export function useEditorPanelContentState({ } delete fileLoadRetryAttemptsRef.current[id] setFileContents((prev) => ({ ...prev, [id]: result })) + stampCleanTabDiskBaseline(id, result) } catch (err) { if (fileReadGenerationRef.current[id] !== generation) { return @@ -304,8 +325,23 @@ export function useEditorPanelContentState({ [] ) - const reloadFileContent = useCallback( + // Why: the changed-on-disk banner's explicit reload on an unstaged diff tab + // must refetch the diff body, not the plain file content — one entry point + // branches on the tab mode so every consumer reloads the right store. + const reloadContent = useCallback( (file: OpenFile): void => { + if (file.mode === 'diff') { + setDiffContents((prev) => { + if (!prev[file.id]) { + return prev + } + const next = { ...prev } + delete next[file.id] + return next + }) + void loadDiffContent(file, { force: true }) + return + } delete fileLoadRetryAttemptsRef.current[file.id] setFileContents((prev) => { if (!prev[file.id]) { @@ -319,7 +355,7 @@ export function useEditorPanelContentState({ force: true }) }, - [loadFileContent] + [loadDiffContent, loadFileContent] ) useEffect(() => { @@ -507,5 +543,5 @@ export function useEditorPanelContentState({ setDiffContents ) - return { fileContents, diffContents, reloadFileContent } + return { fileContents, diffContents, reloadContent } } diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts index 3b70c27b442..19650b1cd64 100644 --- a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts +++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts @@ -43,6 +43,12 @@ export function useEditorPanelExternalContentEvents({ return } for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) { + // Why: a dirty file keeps its unsaved buffer (issue #7265) — it is + // marked changed-on-disk upstream and resolves via the editor banner, + // a save, or a later clean reload. Reloading here would clobber it. + if (file.isDirty) { + continue + } if (file.mode === 'edit' || file.mode === 'markdown-preview') { // Why: external writes must replace any in-flight pre-change read so // the tab shows the new on-disk content, not a stale dedupe result. diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx index 5b7f7bc94db..7709c4f9545 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx @@ -159,7 +159,7 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', () }) expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal) - // Retry (reloadFileContent) clears the attempt budget for a fresh start. + // Retry (reloadContent) clears the attempt budget for a fresh start. delete attemptsRef.current[file.id] expect(attemptsRef.current[file.id]).toBeUndefined() }) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 9de55f9c95e..2ce15236f40 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -80,6 +80,11 @@ export default function EditorFileTab({ const isConflictReview = file.mode === 'conflict-review' const isCheckDetails = file.mode === 'check-details' const isMarkdownPreviewTab = file.mode === 'markdown-preview' + // Why: only deleted/renamed mean the file is gone from its path, which is + // what strikethrough conveys. 'changed' keeps a normal label — its surface + // is the changed-on-disk banner inside the editor. + const isMissingFileMutation = + file.externalMutation === 'deleted' || file.externalMutation === 'renamed' const resolvedLanguage = file.mode === 'diff' ? detectLanguage(file.relativePath) @@ -308,7 +313,7 @@ export default function EditorFileTab({ /> ) : ( { if (file.isPreview && onMakePermanent) { @@ -329,12 +334,12 @@ export default function EditorFileTab({ {tabLabel} )} - {file.externalMutation && !isRenaming && ( + {isMissingFileMutation && !isRenaming && ( {file.externalMutation} )} - {tabStatus && !isRenaming && !file.externalMutation && ( + {tabStatus && !isRenaming && !isMissingFileMutation && ( { dispose() }) + it('marks a dirty edit tab changed-on-disk instead of reloading it', () => { + const dirtyFile = { + ...fileNotes, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyFile], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + expect(notifyEditorExternalFileChange).not.toHaveBeenCalled() + dispose() + }) + + it('still reloads a clean sibling diff tab when the edit tab for the path is dirty', () => { + const dirtyFile = { + ...fileNotes, + isDirty: true + } + const cleanDiffTab = { + id: 'diff-notes', + worktreeId: 'wt-1', + worktreePath: '/repo', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + mode: 'diff' as const, + diffSource: 'unstaged' as const, + isDirty: false + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyFile, cleanDiffTab], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile, cleanDiffTab] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + worktreePath: '/repo', + relativePath: 'notes.md', + runtimeEnvironmentId: null + }) + dispose() + }) + + it('does not mark a dirty tab for the echo of Orca’s own save', async () => { + const dirtyFile = { + ...fileNotes, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyFile], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) + const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false }) + vi.stubGlobal('window', { api: { fs: { readFile } } }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + recordSelfWrite('/repo/notes.md', 'orca save') + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('marks a dirty tab when a genuine external write lands inside the self-write TTL', async () => { + const dirtyFile = { + ...fileNotes, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyFile], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) + const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false }) + vi.stubGlobal('window', { api: { fs: { readFile } } }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + recordSelfWrite('/repo/notes.md', 'orca save') + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('shares one echo-verification read across a burst of watcher payloads', async () => { + const dirtyFile = { + ...fileNotes, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyFile], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) + const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false }) + vi.stubGlobal('window', { api: { fs: { readFile } } }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + recordSelfWrite('/repo/notes.md', 'orca save') + // Why: SSH poll + event streams can deliver several payloads for one + // write; each verification is a full-file read, so a burst must share + // the in-flight read instead of stacking network round-trips. + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + await vi.advanceTimersByTimeAsync(100) + + expect(readFile).toHaveBeenCalledTimes(1) + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('marks a lone dirty unstaged-diff tab changed-on-disk', () => { + const dirtyDiffTab = { + id: 'diff-notes', + worktreeId: 'wt-1', + worktreePath: '/repo', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + mode: 'diff' as const, + diffSource: 'unstaged' as const, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyDiffTab], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyDiffTab] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).toHaveBeenCalledWith('diff-notes', 'changed') + expect(notifyEditorExternalFileChange).not.toHaveBeenCalled() + dispose() + }) + + it('does not let an update event clear a changed-on-disk mark while the tab stays dirty', () => { + const dirtyChangedFile = { + ...fileNotes, + isDirty: true, + externalMutation: 'changed' as const + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [dirtyChangedFile], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyChangedFile] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', null) + dispose() + }) + it('does not reload a branch-compare combined diff for working-tree changes', () => { const branchDiffTab = { id: 'wt-1::all-diffs::branch', diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 157b91df034..9fd18202b98 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -8,6 +8,7 @@ import { basename, joinPath } from '@/lib/path' import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { + canAutoSaveOpenFile, getOpenFilesForExternalFileChange, isExternalReloadableEditorTab, isWorkingTreeCombinedDiffTab, @@ -28,6 +29,7 @@ import { type WorktreeFileChangeEventDetail } from './worktree-file-change-event' import { isGitRepoKind } from '../../../shared/repo-kind' +import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' // Why: atomic-write patterns (Claude Code's Edit tool, editors like vim, // VSCode) land as a short burst of `update` events — or `delete + create` on @@ -545,7 +547,10 @@ export function createExternalWatchEventHandler( // Why: if a previously-deleted file reappears at the same path (e.g. // the user ran `git checkout`), clear the tombstone so the tab returns // to its normal state and any non-dirty content gets reloaded below. - // `createOrUpdatePaths` was collected above. + // `createOrUpdatePaths` was collected above. Scoped to deleted/renamed: + // a 'changed' mark means the file was rewritten while the tab was dirty, + // so a further update event must not clear it — it resolves via reload, + // save, or the reload path below. if (createOrUpdatePaths.size > 0) { const state = useAppStore.getState() for (const file of state.openFiles) { @@ -553,7 +558,7 @@ export function createExternalWatchEventHandler( file.worktreeId === target.worktreeId && openFileRuntimeOwner(file) === target.runtimeEnvironmentId && (file.mode === 'edit' || file.mode === 'markdown-preview') && - file.externalMutation && + (file.externalMutation === 'deleted' || file.externalMutation === 'renamed') && createOrUpdatePaths.has(normalizeRuntimePathForComparison(file.filePath)) ) { state.setExternalMutation(file.id, null) @@ -637,11 +642,27 @@ export function createExternalWatchEventHandler( } continue } - if (matching.some((f) => f.isDirty)) { - if (hasCombinedDiffConsumer) { - scheduleDebouncedExternalReload(notification) + const dirtyMatches = matching.filter((f) => f.isDirty) + if (dirtyMatches.length > 0) { + // Why: an external write landing on a dirty tab must not vanish + // silently (issue #7265) — the user was left with a stale tab and a + // save that clobbered the newer disk content. Mark the tab so the + // editor shows a changed-on-disk banner with an explicit reload path. + scheduleChangedOnDiskMark( + target, + notification, + // Why: canAutoSaveOpenFile is exactly the set of tabs that can hold + // unsaved edits (edit + unstaged diff) — the tabs the banner serves. + dirtyMatches.filter((dirtyFile) => canAutoSaveOpenFile(dirtyFile)).map((f) => f.id) + ) + if (dirtyMatches.length === matching.length) { + if (hasCombinedDiffConsumer) { + scheduleDebouncedExternalReload(notification) + } + continue } - continue + // Clean sibling tabs (e.g. an unstaged diff of the same path) still + // reload below; every notification consumer skips dirty files. } const absolutePath = joinPath(notification.worktreePath, notification.relativePath) const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId) @@ -665,6 +686,90 @@ export function createExternalWatchEventHandler( return { handleFsChanged, dispose } } +const inFlightEchoVerificationReads = new Map>() + +// Why: one save echo can arrive as a burst of watcher payloads (SSH poll + +// event streams), and each verification is a full-file read — on remote +// transports a network round-trip. Concurrent payloads for the same file +// share the in-flight read instead of stacking duplicates. +function readFileForEchoVerification(args: { + runtimeEnvironmentId: string | null | undefined + filePath: string + relativePath: string + worktreeId: string | null | undefined + connectionId: string | undefined +}): ReturnType { + const key = `${args.runtimeEnvironmentId ?? ''}::${args.connectionId ?? ''}::${args.filePath}` + let pending = inFlightEchoVerificationReads.get(key) + if (!pending) { + pending = readRuntimeFileContent({ + settings: args.runtimeEnvironmentId + ? { activeRuntimeEnvironmentId: args.runtimeEnvironmentId } + : null, + filePath: args.filePath, + relativePath: args.relativePath, + worktreeId: args.worktreeId ?? undefined, + connectionId: args.connectionId + }) + inFlightEchoVerificationReads.set(key, pending) + const release = (): void => { + if (inFlightEchoVerificationReads.get(key) === pending) { + inFlightEchoVerificationReads.delete(key) + } + } + pending.then(release, release) + } + return pending +} + +function markTabsChangedOnDisk(fileIds: string[], connectionId: string | undefined): void { + const state = useAppStore.getState() + for (const fileId of fileIds) { + const file = state.openFiles.find((f) => f.id === fileId) + // Why: echo verification resolves async — a save or reload may already + // have resolved the conflict; the helper only marks still-dirty tabs. + if (file) { + markFileChangedOnDisk(state, file, { connectionId, origin: 'live' }) + } + } +} + +function scheduleChangedOnDiskMark( + target: WatchedTarget, + notification: ExternalWatchNotification, + fileIds: string[] +): void { + if (fileIds.length === 0) { + return + } + const absolutePath = joinPath(notification.worktreePath, notification.relativePath) + const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId) + // Why: the fs event may be the echo of Orca's own save racing keystrokes + // typed during the write. Marking on the echo would show a false "changed + // on disk" banner, so verify disk really differs from our last write. + if (!recentSelfWrite || recentSelfWrite.content === null) { + markTabsChangedOnDisk(fileIds, target.connectionId) + return + } + void readFileForEchoVerification({ + runtimeEnvironmentId: target.runtimeEnvironmentId, + filePath: absolutePath, + relativePath: notification.relativePath, + worktreeId: notification.worktreeId, + connectionId: target.connectionId + }) + .then((result) => { + if (result.isBinary || result.content !== recentSelfWrite.content) { + markTabsChangedOnDisk(fileIds, target.connectionId) + } + }) + .catch(() => { + // Why: unreadable disk state can't disprove an external change — keep + // the conflict visible rather than risk a silent overwrite. + markTabsChangedOnDisk(fileIds, target.connectionId) + }) +} + function scheduleSelfWriteAwareExternalReload( target: WatchedTarget, notification: ExternalWatchNotification, @@ -680,8 +785,8 @@ function scheduleSelfWriteAwareExternalReload( // Why: a recent self-write stamp only proves the path changed recently; an // agent can write a newer version inside the same TTL. Compare disk content // with the saved text so we suppress only the echo of Orca's own write. - void readRuntimeFileContent({ - settings: runtimeEnvironmentId ? { activeRuntimeEnvironmentId: runtimeEnvironmentId } : null, + void readFileForEchoVerification({ + runtimeEnvironmentId, filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, @@ -706,7 +811,9 @@ function scheduleSelfWriteAwareExternalReload( function hasCleanExternalReloadTarget(notification: ExternalWatchNotification): boolean { const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification) - return matching.length > 0 && matching.every((file) => !file.isDirty) + // Why: one clean target is enough — every notification consumer skips dirty + // files per-file, so a dirty sibling tab no longer vetoes the reload. + return matching.some((file) => !file.isDirty) } export function getOverflowExternalReloadTargets( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d15a81c4661..7a51b6f4c6a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11522,6 +11522,24 @@ }, "richMarkdownLargeTextPaste": { "tooLarge": "Paste is too large." + }, + "ExternalFileChangeBanner": { + "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "5c02de9b31": "Reloaded from disk", + "d1e830fa22": "Undo", + "90b2ce7d43": "Compare" + }, + "ExternalFileChangeCompareDialog": { + "4b8de20a11": "File changed on disk", + "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", + "8fe30ab254": "Reading file from disk...", + "e2b1cd0393": "Could not read the file from disk: {{value0}}", + "b6cf20d514": "The file on disk is binary — no text comparison available.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "2c8f1e07b9": "Loading comparison..." } }, "diff": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84d5a861a54..5551dae295e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11522,6 +11522,24 @@ "3e6c9a2b71": "pendiente", "4f7d0c3e88": "pasos fallidos", "5a8e1d4f23": " · " + }, + "ExternalFileChangeBanner": { + "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "5c02de9b31": "Reloaded from disk", + "d1e830fa22": "Undo", + "90b2ce7d43": "Compare" + }, + "ExternalFileChangeCompareDialog": { + "4b8de20a11": "File changed on disk", + "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", + "8fe30ab254": "Reading file from disk...", + "e2b1cd0393": "Could not read the file from disk: {{value0}}", + "b6cf20d514": "The file on disk is binary — no text comparison available.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "2c8f1e07b9": "Loading comparison..." } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 20e253a584d..158ec815bd1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11522,6 +11522,24 @@ "3e6c9a2b71": "保留中", "4f7d0c3e88": "失敗したステップ", "5a8e1d4f23": " · " + }, + "ExternalFileChangeBanner": { + "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "5c02de9b31": "Reloaded from disk", + "d1e830fa22": "Undo", + "90b2ce7d43": "Compare" + }, + "ExternalFileChangeCompareDialog": { + "4b8de20a11": "File changed on disk", + "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", + "8fe30ab254": "Reading file from disk...", + "e2b1cd0393": "Could not read the file from disk: {{value0}}", + "b6cf20d514": "The file on disk is binary — no text comparison available.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "2c8f1e07b9": "Loading comparison..." } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cd83a58fa86..aba282e254e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11522,6 +11522,24 @@ "3e6c9a2b71": "보류 중", "4f7d0c3e88": "단계 실패", "5a8e1d4f23": " · " + }, + "ExternalFileChangeBanner": { + "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "5c02de9b31": "Reloaded from disk", + "d1e830fa22": "Undo", + "90b2ce7d43": "Compare" + }, + "ExternalFileChangeCompareDialog": { + "4b8de20a11": "File changed on disk", + "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", + "8fe30ab254": "Reading file from disk...", + "e2b1cd0393": "Could not read the file from disk: {{value0}}", + "b6cf20d514": "The file on disk is binary — no text comparison available.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "2c8f1e07b9": "Loading comparison..." } }, "diff": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5be00891377..582ad7e30de 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11522,6 +11522,24 @@ "3e6c9a2b71": "等待中", "4f7d0c3e88": "步骤失败", "5a8e1d4f23": " · " + }, + "ExternalFileChangeBanner": { + "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "5c02de9b31": "Reloaded from disk", + "d1e830fa22": "Undo", + "90b2ce7d43": "Compare" + }, + "ExternalFileChangeCompareDialog": { + "4b8de20a11": "File changed on disk", + "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", + "8fe30ab254": "Reading file from disk...", + "e2b1cd0393": "Could not read the file from disk: {{value0}}", + "b6cf20d514": "The file on disk is binary — no text comparison available.", + "3fa2b8d417": "Reload from Disk", + "a95d02c644": "Keep My Edits", + "2c8f1e07b9": "Loading comparison..." } }, "diff": { diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 88ce7a68db4..99dd4ec5970 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -78,4 +78,49 @@ describe('workspace session editor drafts', () => { }) ]) }) + + it('persists the disk baseline signature only alongside a dirty draft', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/tmp/dirty.md', + filePath: '/tmp/dirty.md', + relativePath: 'dirty.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: true, + lastKnownDiskSignature: 'abc123' + } as never, + { + id: '/tmp/clean.md', + filePath: '/tmp/clean.md', + relativePath: 'clean.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false, + lastKnownDiskSignature: 'def456' + } as never + ], + editorDrafts: { + '/tmp/dirty.md': 'unsaved edits' + } + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']).toEqual([ + expect.objectContaining({ + filePath: '/tmp/dirty.md', + dirtyDraftContent: 'unsaved edits', + lastKnownDiskSignature: 'abc123' + }), + // Why: a clean tab has no draft to conflict-check on restore, and + // persisting the signature anyway would bloat every session write. + expect.not.objectContaining({ + lastKnownDiskSignature: expect.any(String) + }) + ]) + }) }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 88cb8ca5e72..4970840d5fb 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -135,7 +135,13 @@ export function buildEditorSessionData( language: f.language, isPreview: f.isPreview || undefined, runtimeEnvironmentId: f.runtimeEnvironmentId, - ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}) + ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}), + // Why: the edit baseline travels with the dirty draft so a restore can + // re-derive a changed-on-disk conflict before autosave may overwrite an + // agent write that landed while the app was closed. + ...(dirtyDraftContent !== undefined && f.lastKnownDiskSignature + ? { lastKnownDiskSignature: f.lastKnownDiskSignature } + : {}) }) const ids = editFileIdsByWorktree[f.worktreeId] ?? (editFileIdsByWorktree[f.worktreeId] = new Set()) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 5b451cbbc7c..4fdd5fb76f5 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -251,8 +251,21 @@ export type OpenFile = { // disk while it's open, we keep the tab around so the user can still see // (and potentially save) their in-memory content. The tab surfaces this as // a strikethrough label plus a "deleted"/"renamed" suffix. Cleared if the - // file reappears on disk at its original path. - externalMutation?: 'deleted' | 'renamed' + // file reappears on disk at its original path. 'changed' means the file was + // rewritten on disk while this tab held unsaved edits (issue #7265): the + // buffer is preserved and the editor shows a changed-on-disk banner instead + // of tab strikethrough. + externalMutation?: 'deleted' | 'renamed' | 'changed' + /** Why: signature of the disk content this tab's edits are based on (last + * load or save). Persisted with dirty drafts so a restore can re-derive a + * changed-on-disk conflict from ground truth — an agent write that landed + * while the app was closed must not be clobbered by a resumed autosave. */ + lastKnownDiskSignature?: string + /** Why: set at hydration for restored dirty tabs; suspends autosave until + * the restored-tab conflict scan has compared disk against the baseline. + * Without this hard gate the scan's async read merely races the autosave + * timer, and a slow (SSH/runtime) read loses the race. Not persisted. */ + pendingDiskBaselineVerification?: boolean /** Why: diff bodies are cached in EditorPanel. Re-selecting an existing diff * tab from the tree bumps this so the panel refetches instead of reusing a * stale snapshot. */ @@ -497,7 +510,9 @@ export type EditorSlice = { setActiveFile: (fileId: string) => void reorderFiles: (fileIds: string[]) => void markFileDirty: (fileId: string, dirty: boolean) => void - setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | null) => void + setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void + setLastKnownDiskSignature: (fileId: string, signature: string) => void + clearPendingDiskBaselineVerification: (fileId: string) => void clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -2469,6 +2484,32 @@ export const createEditorSlice: StateCreator = (s } }), + setLastKnownDiskSignature: (fileId, signature) => + set((s) => { + const file = s.openFiles.find((f) => f.id === fileId) + if (!file || file.lastKnownDiskSignature === signature) { + return s + } + return { + openFiles: s.openFiles.map((f) => + f.id === fileId ? { ...f, lastKnownDiskSignature: signature } : f + ) + } + }), + + clearPendingDiskBaselineVerification: (fileId) => + set((s) => { + const file = s.openFiles.find((f) => f.id === fileId) + if (!file?.pendingDiskBaselineVerification) { + return s + } + return { + openFiles: s.openFiles.map((f) => + f.id === fileId ? { ...f, pendingDiskBaselineVerification: undefined } : f + ) + } + }), + clearUntitled: (fileId) => set((s) => ({ openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f)) @@ -4300,6 +4341,15 @@ export const createEditorSlice: StateCreator = (s isDirty: pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, + lastKnownDiskSignature: pf.lastKnownDiskSignature, + // Why: hard-suspends autosave until the restored-tab conflict scan + // verifies disk against the baseline — an async race would let a + // slow remote read lose to the autosave timer and clobber an + // offline agent write. + pendingDiskBaselineVerification: + pf.dirtyDraftContent !== undefined && pf.lastKnownDiskSignature !== undefined + ? true + : undefined, mode: 'edit' }) } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 5df0c89d8ff..cb4169cb1dd 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -1364,6 +1364,25 @@ const terminalPaneSplitSchema = z }) .strict() +// Why: measures the changed-on-disk conflict flow (issue #7265) — how often +// conflicts surface per transport (false-banner detection on ssh/runtime +// echoes) and which resolution users pick. Deliberately path-free. +const editorExternalChangeConflictShownSchema = z + .object({ + surface: z.enum(['edit', 'unstaged-diff']), + transport: z.enum(['local', 'ssh', 'runtime']), + origin: z.enum(['live', 'restore']) + }) + .strict() + +const editorExternalChangeConflictActionSchema = z + .object({ + action: z.enum(['reload', 'keep', 'compare', 'undo_reload', 'save_overwrite']), + surface: z.enum(['edit', 'unstaged-diff']), + transport: z.enum(['local', 'ssh', 'runtime']) + }) + .strict() + // ── Event registry: the one record the validator consumes ─────────────── // // The validator does `eventSchemas[name].safeParse(props)`. `EventMap` is @@ -1451,6 +1470,9 @@ export const eventSchemas = { setup_guide_step_completed: setupGuideStepCompletedSchema, terminal_pane_split: terminalPaneSplitSchema, + editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema, + editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, + smart_sort_class_distribution: smartSortClassDistributionSchema, smart_sort_class_1_promotion: smartSortClass1PromotionSchema, smart_to_recent_switch: smartToRecentSwitchSchema diff --git a/src/shared/types.ts b/src/shared/types.ts index 92715f7861a..98749e1012f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1022,6 +1022,9 @@ export type PersistedOpenFile = { runtimeEnvironmentId?: string | null /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ dirtyDraftContent?: string + /** Signature of the disk content the dirty draft is based on; lets restore + * re-derive a changed-on-disk conflict from ground truth. */ + lastKnownDiskSignature?: string } export type WorkspaceSessionState = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 07e98996e34..22657617c10 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -161,7 +161,8 @@ const persistedOpenFileSchema = z.object({ language: z.string(), isPreview: z.boolean().optional(), runtimeEnvironmentId: z.string().nullable().optional(), - dirtyDraftContent: z.string().optional() + dirtyDraftContent: z.string().optional(), + lastKnownDiskSignature: z.string().optional() }) // ─── Browser ──────────────────────────────────────────────────────── From 37ba94a228950deee1a3bb3f1e078d2a207d15d5 Mon Sep 17 00:00:00 2001 From: Luis Sebastian Urrutia Fuentes Date: Wed, 8 Jul 2026 02:10:53 +0200 Subject: [PATCH 047/152] fix(i18n): polish Spanish locale copy (#7422) --- src/renderer/src/i18n/locales/es.json | 56 +++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5551dae295e..95f2fd339b8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1107,7 +1107,7 @@ }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "Solo disponible para proyectos Git locales.", - "d861de981b": "Checkout sparse", + "d861de981b": "Sparse checkout", "090cfedeb4": "Escribe una nota", "f8728aa4f9": "Nota", "0ee17638fe": "Nombre del espacio de trabajo", @@ -2004,7 +2004,7 @@ "643d7e9496": "upstream", "51d1608920": "Origen", "cdc9bd64fa": "compacto", - "30b2c9df91": "Ascendente" + "30b2c9df91": "Upstream" }, "PRFilterDropdowns": { "979be3cf6b": "Asignado", @@ -2251,7 +2251,7 @@ "38ca0b1400": "Esto elimina permanentemente sus archivos locales. No puedes deshacer esto.", "c4f4782c02": "No sugerido", "0a2e3c7cba": "Revisar", - "e97e4580c7": "Sucio", + "e97e4580c7": "Con cambios", "9623a5107d": "commits sin push", "e8b3741ff7": "ignorado", "a9957007eb": "Ignorar {{value0}}", @@ -4655,9 +4655,9 @@ "566d9a99ab": "_token=…; minimax_group_id_v2=…", "f38b9cc4bd": "Reemplazar", "590a3130f9": "Guardar", - "79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y copia el encabezado Cookie de DevTools (Red → cualquier solicitud a remains → Cookie).", + "79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y, en DevTools, copia el valor del encabezado Cookie (Red → una solicitud de remains → Cookie).", "9dd50d3f75": "Avanzado", - "174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al workspace o modelo equivocado.", + "174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al espacio de trabajo o modelo equivocado.", "bf160bb6c0": "Anulación de Group ID", "b1e2743313": "Opcional. Déjalo en blanco para usar minimax_group_id_v2 de la cookie.", "0747d6391a": "Usar el Group ID de la cookie", @@ -4665,20 +4665,20 @@ "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", "3c92b0d31c": "general", "0d8e77bc40": "Abrir consola", - "0b8c1c7e02": "Almacenado localmente", - "1fd1b1b6b4": "Cookie no configurada", + "0b8c1c7e02": "Almacenada localmente", + "1fd1b1b6b4": "Cookie sin configurar", "5e08b0fe57": "Se almacena localmente y se envía solo a platform.minimax.io para actualizar el uso.", "43d7a45b97": "Cómo copiar", - "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", + "b8a4f21c3e": "Pega el valor del encabezado Cookie de DevTools", "53f7b8c7a2": "Última actualización: {{value0}}", "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", "3a30aaf526": "ahora mismo", "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", "24560fe830": "Abre DevTools.", - "4cab0fa42d": "Ve a la pestaña Red y activa Preserve log.", + "4cab0fa42d": "Ve a la pestaña Red y activa Conservar registro.", "bee4e63e1c": "Recarga la página.", "87f814af6f": "Filtra por remains y selecciona la solicitud coding_plan/remains.", - "435df0ee51": "En Request Headers, copia el valor de Cookie.", + "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." @@ -6987,7 +6987,7 @@ "40e5c3c285": "kimi", "c927a155d5": "Muestra el uso de la suscripción de Kimi en la barra de estado.", "3a6c028ea8": "Uso de Kimi", - "25e51b62ee": "rate limit", + "25e51b62ee": "límite de uso", "d9e7cef86f": "cookie", "d16378a88f": "minimax", "e46178eb1b": "Muestra el uso de la suscripción de MiniMax en la barra de estado.", @@ -8689,13 +8689,13 @@ "DevToolsPane": { "nativeActionLayoutCheck": "Comprobación de diseño de acción nativa", "secondary": "Secundario", - "secondaryActionClicked": "Acción secundaria pulsada", + "secondaryActionClicked": "Clic en acción secundaria", "primaryAction": "Acción principal", - "primaryActionClicked": "Acción principal pulsada", + "primaryActionClicked": "Clic en acción principal", "branchHasChanges": "la rama tiene cambios", - "viewChangesClicked": "Ver cambios pulsado", - "forceDeleteClicked": "Forzar eliminación pulsado", - "devOnlyCallback": "Callback solo para desarrolladores.", + "viewChangesClicked": "Clic en Ver cambios", + "forceDeleteClicked": "Clic en Forzar eliminación", + "devOnlyCallback": "Callback solo para desarrollo.", "infoToast": "Toast informativo", "infoToastDescription": "Texto informativo largo sin acción explícita.", "localCanaryBehind": "La rama canary local está detrás de origin/canary", @@ -8707,14 +8707,14 @@ "errorToastDescription": "Texto de error largo sin acciones de recuperación.", "failedToSyncWorkspaceMetadata": "Error al sincronizar metadatos del espacio de trabajo", "nativeActionToast": "Toast de acción nativa", - "nativeActionToastDescription": "Utiliza las ranuras de acción y cancelación de Sonner.", + "nativeActionToastDescription": "Usa los slots de acción y cancelación de Sonner.", "deleteFailureToast": "Toast de error al eliminar", "deleteFailureToastDescription": "Pie personalizado con Ver y Forzar eliminación.", - "behindBaseRefToast": "Toast de retraso respecto a la rama base", + "behindBaseRefToast": "Toast de referencia base atrasada", "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", - "notificationPlayground": "Área de prueba de notificaciones", - "notificationPlaygroundDescription": "Disparadores solo para desarrolladores para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", - "devOnly": "Solo desarrollo" + "notificationPlayground": "Panel de prueba de notificaciones", + "notificationPlaygroundDescription": "Disparadores solo para desarrollo para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", + "devOnly": "Solo para desarrollo" }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -8774,12 +8774,12 @@ "agentArgs": "Argumentos de la CLI", "commandTemplate": "Plantilla del comando", "more": "+{{count}} más", - "plural": "Los guardados globales no cambiarán {{count}} repositorios con sus propias recetas.", + "plural": "Guardar a nivel global no cambiará {{count}} repositorios con sus propias recetas.", "recipe": "Receta", "review": "Revisar", "reviewFirst": "Revisar primero", - "singular": "Los guardados globales no cambiarán 1 repositorio con su propia receta.", - "tooltipTitle": "Anulaciones del repositorio" + "singular": "Guardar a nivel global no cambiará 1 repositorio con su propia receta.", + "tooltipTitle": "Anulaciones de repositorio" } }, "right": { @@ -9248,8 +9248,8 @@ "c1a8f3e204": "1 commit por detrás de {{value0}}", "d2b9g4f315": "{{value0}} commits por detrás de {{value1}}", "e9c2a5d416": "A la par con {{value0}}", - "4b4a7de138": "Open review page in browser", - "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." + "4b4a7de138": "Abrir página de revisión en el navegador", + "createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el problema y vuelve a intentar Crear PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", @@ -9682,7 +9682,7 @@ "projectScope": "Proyecto", "currentProjectLower": "proyecto actual", "project": "Proyecto", - "hostScopeAriaLabel": "Session History host: {{value0}}", + "hostScopeAriaLabel": "Host del historial de sesiones: {{value0}}", "host": "Host" }, "AiVaultSessionDetails": { @@ -12475,7 +12475,7 @@ "allow": "Permitir", "deny": "Denegar" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "No se entregó; revisa el terminal" }, "tab": { "bar": { From f311539f9c8bbbade89c53ce0a527dd63df45855 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:50:37 -0700 Subject: [PATCH 048/152] Split agent-detection into title status/display/identity domain modules (title evidence plan, PR 3) (#7612) Co-authored-by: Orca --- src/shared/agent-detection.ts | 515 ++---------------------- src/shared/terminal-title-agent-type.ts | 199 ++++++++- src/shared/terminal-title-display.ts | 99 +++++ src/shared/terminal-title-status.ts | 230 +++++++++++ 4 files changed, 541 insertions(+), 502 deletions(-) create mode 100644 src/shared/terminal-title-display.ts create mode 100644 src/shared/terminal-title-status.ts diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 80f934b3c31..90e72a93d90 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,501 +1,34 @@ /** - * Shared agent detection utilities — used by both the main process (stats - * collection) and the renderer (activity indicators, unread badges). + * Compatibility barrel for terminal-title agent detection — used by the main + * process (stats collection), the renderer (activity indicators, unread + * badges), and shared siblings. * - * Why shared: the main process needs the same OSC title extraction and agent - * status detection for stat tracking that the renderer uses for UI indicators. - * Duplicating this logic would risk drift between the two detection paths. + * The implementation was split into domain modules in Phase 3 of the title + * evidence work: identity/label detection → `terminal-title-agent-type`, status + * classification → `terminal-title-status`, and display normalization → + * `terminal-title-display`. This barrel is kept so the existing main/mobile/ + * renderer import paths that reference `agent-detection` stay stable. */ -import { - AGY_AGENT_NAME_RE, - DROID_AGENT_NAME_RE, - HERMES_AGENT_NAME_RE, - titleHasAgentName, - titleHasAnyLegacyAgentName -} from './agent-name-token-match' -import { - getPiCompatibleSyntheticAgentLabel, - getPiCompatibleSyntheticAgentStatus, - isLegacyPiCompatibleTitle -} from './pi-compatible-synthetic-title' - -// Re-export so existing `agent-detection` importers keep working. -export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' +export { titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' - -export type AgentStatus = 'working' | 'permission' | 'idle' - -const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) -const CLAUDE_MANAGEMENT_TITLE_RE = - /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i - -const GEMINI_WORKING = '\u2726' // ✦ -const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ -const GEMINI_IDLE = '\u25C7' // ◇ -const GEMINI_PERMISSION = '\u270B' // ✋ - -// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles -// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. -// `as const` so consumers receive literal-union types. -const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const - -// Why: working keywords used inside `detectAgentStatusFromTitle` to map -// titles like "Codex working", "Aider thinking", "OpenCode running" to -// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay -// in lock-step when stripping working indicators from stale titles. -const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const - -// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that -// would make the "keyword" part of a larger token. Plain `\b` alone is -// insufficient because `-` is a non-word character in JS regex, so `\bready\b` -// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and -// `r`). -// -// Lookarounds are intentionally ASYMMETRIC: -// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, -// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle -// signal by having the agent name sit earlier in the same path and the -// keyword land right after a path separator. Orca is a cross-platform -// Electron app, so Windows path separators must be handled too. -// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like -// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path -// separators after the keyword are not a false-positive vector in -// practice and blocking them would regress trailing-punctuation titles. -// -// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain -// substring false positives ("already"/"redone"/"idleness"). -export const STRONG_IDLE_KEYWORDS_RE = new RegExp( - `(?= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - -function containsAgentName(title: string): boolean { - return ( - titleHasAnyLegacyAgentName(title) || - AGY_AGENT_NAME_RE.test(title) || - DROID_AGENT_NAME_RE.test(title) || - HERMES_AGENT_NAME_RE.test(title) - ) -} - -function containsAny(title: string, words: readonly string[]): boolean { - const lower = title.toLowerCase() - return words.some((word) => lower.includes(word)) -} - -/** - * Strip working-status indicators from a title so that - * `detectAgentStatusFromTitle` will no longer return 'working'. - * Used to clear stale titles when an agent exits without resetting its title. - */ -export function clearWorkingIndicators(title: string): string { - let cleaned = title - - // Gemini working symbols - cleaned = cleaned.replace(GEMINI_WORKING, '') - cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') - - // Braille spinner characters (U+2800–U+28FF) - // eslint-disable-next-line no-control-regex -- intentional unicode range - cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') - - // Claude Code ". " working prefix - if (cleaned.startsWith('. ')) { - cleaned = cleaned.slice(2) - } - - // Strip working keywords that detectAgentStatusFromTitle would pick up - // when the title also contains an agent name. - if (containsAgentName(cleaned)) { - cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') - } - - // Collapse whitespace after removals - cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() - - return cleaned || title -} - -/** - * Tracks agent status transitions from terminal title changes. - * Fires `onBecameIdle` when an agent transitions from working to idle/permission, - * like haunt's attention flag — the key trigger for unread notifications. - */ -export function createAgentStatusTracker( - onBecameIdle: (title: string) => void, - onBecameWorking?: () => void, - onAgentExited?: () => void -): { - handleTitle: (title: string) => void - /** Clear accumulated status so a stale working→idle transition cannot fire - * after the owning transport is torn down. */ - reset: () => void -} { - let lastStatus: AgentStatus | null = null - - return { - handleTitle(title: string): void { - const newStatus = detectAgentStatusFromTitle(title) - if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { - onBecameIdle(title) - } - if (lastStatus !== 'working' && newStatus === 'working') { - onBecameWorking?.() - } - // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), - // detectAgentStatusFromTitle returns null. If we were idle or in a permission - // prompt, this means the user exited the agent — clear session-tied state - // (like the prompt-cache countdown). We intentionally do NOT fire this when - // lastStatus is 'working', because active agents can briefly flash shell - // titles during internal operations without actually exiting. - if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { - lastStatus = null - onAgentExited?.() - } - if (newStatus !== null) { - lastStatus = newStatus - } - }, - reset(): void { - lastStatus = null - } - } -} - -/** - * Normalize high-churn agent titles into stable display labels before storing - * them in app state. Gemini CLI can emit per-keystroke title updates, which - * otherwise causes broad rerenders and visible flashing. - */ -export function normalizeTerminalTitle(title: string): string { - if (!title) { - return title - } - - if (isGeminiTerminalTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'permission') { - return `${GEMINI_PERMISSION} Gemini CLI` - } - if (status === 'working') { - return `${GEMINI_WORKING} Gemini CLI` - } - if (status === 'idle') { - return `${GEMINI_IDLE} Gemini CLI` - } - } - - // Why: Pi's titlebar extension animates every 80ms with different braille - // frames. Collapsing those frames into one stable label avoids renderer - // churn while preserving the working/idle transition Orca keys off. - if (isPiAgentTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'working') { - return '\u280b Pi' - } - if (status === 'idle') { - return 'Pi' - } - } - - return title -} - -/** - * Returns true when the terminal title matches Claude Code's title conventions. - * Used to scope prompt-cache-timer behavior to Claude sessions only — other - * agents have different (or no) caching semantics. - */ -export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { - return false - } - const lower = title.toLowerCase() - - // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", - // braille spinners) followed by the task description. The task text can - // legitimately mention other agents, so Claude-specific prefixes must win. - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return true - } - // Why: ". " (working) and "* " (idle) are Claude Code title conventions. - // Other supported agents do not use them, and rejecting titles that mention - // another agent in the task text caused false negatives for real Claude tabs. - if (title.startsWith('. ') || title.startsWith('* ')) { - return true - } - if (containsBrailleSpinner(title)) { - // Why: named non-Claude agents can carry braille spinners too; Claude-only - // prompt-cache paths must not fire for those explicit agent titles. - return !lower.includes('cursor') && !lower.includes('openclaude') - } - // Why: permission/action-required Claude titles can omit the usual prefixes. - // Token-match so cwd/worktree titles like "claude-scratch" do not become - // Claude tabs, while task text that merely mentions Claude still stays out. - const trimmedTitle = title.trimStart() - if ( - trimmedTitle.toLowerCase().startsWith('claude') && - titleHasAgentName(trimmedTitle, 'claude') - ) { - return true - } - - return false -} - -export function isClaudeManagementTitle(title: string): boolean { - return CLAUDE_MANAGEMENT_TITLE_RE.test(title) -} - -export function getAgentLabel(title: string): string | null { - if (isClaudeManagementTitle(title)) { - return null - } - // Why: Claude Code title text is often the task title. If that task mentions - // another CLI, the Claude-specific prefix is the identity signal, not the words. - if ( - title.startsWith(`${CLAUDE_IDLE} `) || - title === CLAUDE_IDLE || - title.startsWith('. ') || - title.startsWith('* ') - ) { - return 'Claude Code' - } - if (isGeminiTerminalTitle(title)) { - return 'Gemini CLI' - } - // Why: Pi-compatible synthetic titles can carry braille spinners, which the - // generic agent-title heuristics would otherwise claim first. - const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) - if (piCompatibleSyntheticAgentLabel) { - return piCompatibleSyntheticAgentLabel - } - // Why: Pi working titles include a braille spinner prefix, which would be - // mistaken for Claude Code if we checked `isClaudeAgent` first. - if (isPiAgentTitle(title)) { - return 'Pi' - } - // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while - // working. Prefer explicit name matches before Claude's generic spinner - // heuristic so mixed-agent hovercards stay truthful. Token-match (not - // substring) so cwd/worktree titles like "opencode-blinker" don't mint a - // false agent identity. - if (titleHasAgentName(title, 'codex')) { - return 'Codex' - } - if (titleHasAgentName(title, 'openclaude')) { - return 'OpenClaude' - } - if (titleHasAgentName(title, 'copilot')) { - return 'GitHub Copilot' - } - if (titleHasAgentName(title, 'grok')) { - return 'Grok' - } - if (titleHasAgentName(title, 'devin')) { - return 'Devin' - } - if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { - return 'Antigravity' - } - if (titleHasAgentName(title, 'opencode')) { - return 'OpenCode' - } - if (titleHasAgentName(title, 'mimo')) { - return 'MiMo Code' - } - if (titleHasAgentName(title, 'aider')) { - return 'Aider' - } - // Why: the cursor-agent native title is the literal string "Cursor Agent" - // (verified against the 2026.04.17 release) — Orca synthesizes the same - // label from hook events so the braille-spinner + agent-name path lights - // up working/permission/idle transitions in the renderer. Match before - // `isClaudeAgent` because Claude's generic braille heuristic would - // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a - // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. - if (titleHasAgentName(title, 'cursor')) { - return 'Cursor' - } - // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. - // Token matching avoids labeling ordinary Android terminal titles as Droid. - if (DROID_AGENT_NAME_RE.test(title)) { - return 'Droid' - } - // Why: synthesized "⠋ Hermes" working titles need to be matched before - // Claude's generic braille-spinner heuristic. - if (HERMES_AGENT_NAME_RE.test(title)) { - return 'Hermes' - } - if (isClaudeAgent(title)) { - return 'Claude Code' - } - - return null -} - -// Why: cursor-agent's native OSC title is the literal string "Cursor Agent" -// across the entire turn — it carries zero working/idle information. Orca -// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - -// action required" for permission) from cursor's hook events; the bare -// native title must be a no-op so cursor's per-turn re-emissions cannot -// stomp the synthesized state back to idle. -const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' - -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { - if (!title) { - return null - } - if (isClaudeManagementTitle(title)) { - return null - } - // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's - // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - - // action required") is either an Orca-synthesized working/permission title - // or a tighter match worth classifying. - if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { - return null - } - - // Gemini CLI symbols are the most specific and should take precedence. - if (title.includes(GEMINI_PERMISSION)) { - return 'permission' - } - if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { - return 'working' - } - if (title.includes(GEMINI_IDLE)) { - return 'idle' - } - - // Why: resolve synthetic Pi/OMP permission/idle labels before the broader - // Pi and braille-spinner checks below. - const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) - if (piCompatibleSyntheticAgentStatus) { - return piCompatibleSyntheticAgentStatus - } - - // Claude Code uses ✳ prefix for idle — must check before braille/agent-name - // because the title text is the task description, not "Claude Code". - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return 'idle' - } - - if (isPiTerminalTitle(title)) { - return 'idle' - } - - if (containsBrailleSpinner(title)) { - return 'working' - } - - const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) - const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) - const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) - const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) - if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { - if (containsAny(title, ['action required', 'permission', 'waiting'])) { - return 'permission' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b` — which treats `-` as a boundary) so titles like - // "~/codex already built" do not classify as idle via the substring - // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. - if (STRONG_IDLE_KEYWORDS_RE.test(title)) { - return 'idle' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b`) so titles like "~/codex reworking diff" or - // "is-thinking-cap" do not classify as working via the substrings - // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in - // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a - // false 'working' is worse than a false 'idle' because it drives - // active-agent UI (spinners, counts). - if (STRONG_WORKING_KEYWORDS_RE.test(title)) { - return 'working' - } - - // Claude Code title prefixes: ". " = working, "* " = idle - if (title.startsWith('. ')) { - return 'working' - } - if (title.startsWith('* ')) { - return 'idle' - } - - // Why: Factory Droid can publish native titles like "Factory Droid needs - // input" while an Execute tool is still sleeping. Droid's hook events are - // authoritative; don't turn a name-only native title into a completion. - if (hasDroidAgentName && !hasLegacyAgentName) { - return null - } - - return 'idle' - } - - return null -} +export { + getAgentLabel, + isClaudeAgent, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiTerminalTitle +} from './terminal-title-agent-type' +export type { AgentStatus } from './terminal-title-status' +export { + createAgentStatusTracker, + detectAgentStatusFromTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './terminal-title-status' +export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display' diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 3671ef92998..21c206dff20 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -1,6 +1,193 @@ -import { getAgentLabel, titleHasAgentName } from './agent-detection' +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName +} from './agent-name-token-match' +import { + getPiCompatibleSyntheticAgentLabel, + isLegacyPiCompatibleTitle +} from './pi-compatible-synthetic-title' import type { TuiAgent } from './types' +export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) +const CLAUDE_MANAGEMENT_TITLE_RE = + /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ +export const GEMINI_IDLE = '\u25C7' // ◇ +export const GEMINI_PERMISSION = '\u270B' // ✋ + +export function containsBrailleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function isGeminiTerminalTitle(title: string): boolean { + // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. + if ( + title.includes(GEMINI_PERMISSION) || + title.includes(GEMINI_WORKING) || + title.includes(GEMINI_SILENT_WORKING) || + title.includes(GEMINI_IDLE) + ) { + return true + } + // Why: Pi/OMP titles include cwd/session text; substring matching made + // paths like "gemini-project" masquerade as Gemini CLI. + if (isPiAgentTitle(title)) { + return false + } + return titleHasAgentName(title, 'gemini') +} + +export function isPiTerminalTitle(title: string): boolean { + return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) +} + +export function isPiAgentTitle(title: string): boolean { + return isLegacyPiCompatibleTitle(title) +} + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only — other + * agents have different (or no) caching semantics. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", + // braille spinners) followed by the task description. The task text can + // legitimately mention other agents, so Claude-specific prefixes must win. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + // Why: ". " (working) and "* " (idle) are Claude Code title conventions. + // Other supported agents do not use them, and rejecting titles that mention + // another agent in the task text caused false negatives for real Claude tabs. + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + // Why: named non-Claude agents can carry braille spinners too; Claude-only + // prompt-cache paths must not fire for those explicit agent titles. + return !lower.includes('cursor') && !lower.includes('openclaude') + } + // Why: permission/action-required Claude titles can omit the usual prefixes. + // Token-match so cwd/worktree titles like "claude-scratch" do not become + // Claude tabs, while task text that merely mentions Claude still stays out. + const trimmedTitle = title.trimStart() + if ( + trimmedTitle.toLowerCase().startsWith('claude') && + titleHasAgentName(trimmedTitle, 'claude') + ) { + return true + } + + return false +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude Code title text is often the task title. If that task mentions + // another CLI, the Claude-specific prefix is the identity signal, not the words. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + // Why: Pi working titles include a braille spinner prefix, which would be + // mistaken for Claude Code if we checked `isClaudeAgent` first. + if (isPiAgentTitle(title)) { + return 'Pi' + } + // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while + // working. Prefer explicit name matches before Claude's generic spinner + // heuristic so mixed-agent hovercards stay truthful. Token-match (not + // substring) so cwd/worktree titles like "opencode-blinker" don't mint a + // false agent identity. + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: the cursor-agent native title is the literal string "Cursor Agent" + // (verified against the 2026.04.17 release) — Orca synthesizes the same + // label from hook events so the braille-spinner + agent-name path lights + // up working/permission/idle transitions in the renderer. Match before + // `isClaudeAgent` because Claude's generic braille heuristic would + // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a + // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. + // Token matching avoids labeling ordinary Android terminal titles as Droid. + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + // Why: synthesized "⠋ Hermes" working titles need to be matched before + // Claude's generic braille-spinner heuristic. + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} + // Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for // agents whose foreground PROCESS name isn't self-identifying (Claude Code runs // as `node`, but its "✳ Claude Code" title resolves here). Agents whose process @@ -24,16 +211,6 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } -function containsBrailleSpinner(title: string): boolean { - for (const char of title) { - const codePoint = char.codePointAt(0) - if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsBrailleSpinner(title) || diff --git a/src/shared/terminal-title-display.ts b/src/shared/terminal-title-display.ts new file mode 100644 index 00000000000..a74678e3433 --- /dev/null +++ b/src/shared/terminal-title-display.ts @@ -0,0 +1,99 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + isGeminiTerminalTitle, + isPiAgentTitle +} from './terminal-title-agent-type' +import { + detectAgentStatusFromTitle, + STRONG_WORKING_KEYWORDS_RE_GLOBAL +} from './terminal-title-status' + +function containsAgentName(title: string): boolean { + return ( + titleHasAnyLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +/** + * Strip working-status indicators from a title so that + * `detectAgentStatusFromTitle` will no longer return 'working'. + * Used to clear stale titles when an agent exits without resetting its title. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + // Gemini working symbols + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + + // Braille spinner characters (U+2800–U+28FF) + // eslint-disable-next-line no-control-regex -- intentional unicode range + cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') + + // Claude Code ". " working prefix + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + + // Strip working keywords that detectAgentStatusFromTitle would pick up + // when the title also contains an agent name. + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + // Collapse whitespace after removals + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + + return cleaned || title +} + +/** + * Normalize high-churn agent titles into stable display labels before storing + * them in app state. Gemini CLI can emit per-keystroke title updates, which + * otherwise causes broad rerenders and visible flashing. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi's titlebar extension animates every 80ms with different braille + // frames. Collapsing those frames into one stable label avoids renderer + // churn while preserving the working/idle transition Orca keys off. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + return title +} diff --git a/src/shared/terminal-title-status.ts b/src/shared/terminal-title-status.ts new file mode 100644 index 00000000000..5d8cd0a45f2 --- /dev/null +++ b/src/shared/terminal-title-status.ts @@ -0,0 +1,230 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { + CLAUDE_IDLE, + containsBrailleSpinner, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + isClaudeManagementTitle, + isPiTerminalTitle +} from './terminal-title-agent-type' + +export type AgentStatus = 'working' | 'permission' | 'idle' + +// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles +// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. +// `as const` so consumers receive literal-union types. +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const + +// Why: working keywords used inside `detectAgentStatusFromTitle` to map +// titles like "Codex working", "Aider thinking", "OpenCode running" to +// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay +// in lock-step when stripping working indicators from stale titles. +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that +// would make the "keyword" part of a larger token. Plain `\b` alone is +// insufficient because `-` is a non-word character in JS regex, so `\bready\b` +// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and +// `r`). +// +// Lookarounds are intentionally ASYMMETRIC: +// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, +// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle +// signal by having the agent name sit earlier in the same path and the +// keyword land right after a path separator. Orca is a cross-platform +// Electron app, so Windows path separators must be handled too. +// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like +// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path +// separators after the keyword are not a false-positive vector in +// practice and blocking them would regress trailing-punctuation titles. +// +// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain +// substring false positives ("already"/"redone"/"idleness"). +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(? lower.includes(word)) +} + +/** + * Tracks agent status transitions from terminal title changes. + * Fires `onBecameIdle` when an agent transitions from working to idle/permission, + * like haunt's attention flag — the key trigger for unread notifications. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void +): { + handleTitle: (title: string) => void + /** Clear accumulated status so a stale working→idle transition cannot fire + * after the owning transport is torn down. */ + reset: () => void +} { + let lastStatus: AgentStatus | null = null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), + // detectAgentStatusFromTitle returns null. If we were idle or in a permission + // prompt, this means the user exited the agent — clear session-tied state + // (like the prompt-cache countdown). We intentionally do NOT fire this when + // lastStatus is 'working', because active agents can briefly flash shell + // titles during internal operations without actually exiting. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + reset(): void { + lastStatus = null + } + } +} + +// Why: cursor-agent's native OSC title is the literal string "Cursor Agent" +// across the entire turn — it carries zero working/idle information. Orca +// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - +// action required" for permission) from cursor's hook events; the bare +// native title must be a no-op so cursor's per-turn re-emissions cannot +// stomp the synthesized state back to idle. +const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title) { + return null + } + if (isClaudeManagementTitle(title)) { + return null + } + // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's + // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - + // action required") is either an Orca-synthesized working/permission title + // or a tighter match worth classifying. + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + // Gemini CLI symbols are the most specific and should take precedence. + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + // Claude Code uses ✳ prefix for idle — must check before braille/agent-name + // because the title text is the task description, not "Claude Code". + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + + if (isPiTerminalTitle(title)) { + return 'idle' + } + + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) + if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: hyphen/word-char-aware boundary match (not plain substring, and + // stricter than `\b` — which treats `-` as a boundary) so titles like + // "~/codex already built" do not classify as idle via the substring + // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + // Why: hyphen/word-char-aware boundary match (not plain substring, and + // stricter than `\b`) so titles like "~/codex reworking diff" or + // "is-thinking-cap" do not classify as working via the substrings + // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in + // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a + // false 'working' is worse than a false 'idle' because it drives + // active-agent UI (spinners, counts). + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + + // Claude Code title prefixes: ". " = working, "* " = idle + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Factory Droid can publish native titles like "Factory Droid needs + // input" while an Execute tool is still sleeping. Droid's hook events are + // authoritative; don't turn a name-only native title into a completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' + } + + return null +} From 5c3c2f2b3daf9d8563581c389712d805bfb256a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:55:27 +0000 Subject: [PATCH 049/152] release: v1.4.129-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b853e94bfd..a3a4d50c3e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.127-rc.5", + "version": "1.4.129-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 01f6cb6de904dea8e7e1c901d4755bf4954cd0bc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:41:04 -0700 Subject: [PATCH 050/152] Add backup WeChat QR image --- docs/assets/wechat-qr-backup.jpg | Bin 0 -> 211009 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/wechat-qr-backup.jpg diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a6392babbceff75e298d0dff49ec84af33b8e083 GIT binary patch literal 211009 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
~|lts!5>pu{Cq@`F!~3D|&5J>lbS5)Z6 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%0D~ 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

ebFMiFD!7(hiP=;!Pluw=$JhHEoq=WVhNHf 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 z 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

(EM#s`Y56NT~Z80;UMG?H35ny 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 Date: Tue, 7 Jul 2026 18:42:57 -0700 Subject: [PATCH 051/152] Add backup WeChat QR to main README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e9bc05d3f3..9d712389de8 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. +- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. WeChat QR code for the Orca community + Backup WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. From 98bee1b41942f5775784bb07389e4d85d82fc9a1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:01:42 -0700 Subject: [PATCH 052/152] Fix un-removable SSH ghost worktrees after host remove/re-add (#7753) Co-authored-by: Orca --- src/main/ipc/repos.ts | 17 ++ src/main/ipc/ssh.ts | 30 ++- src/main/ipc/worktrees.test.ts | 76 ++++++ src/main/ipc/worktrees.ts | 62 +++++ src/main/persistence.test.ts | 130 ++++++++++ src/main/persistence.ts | 148 +++++++++++- src/main/ssh/ssh-connection-store.test.ts | 158 +++++++++++- src/main/ssh/ssh-connection-store.ts | 36 +++ src/main/ssh/ssh-target-readoption.test.ts | 198 +++++++++++++++ src/main/ssh/ssh-target-readoption.ts | 99 ++++++++ src/preload/api-types.ts | 9 + src/preload/index.ts | 7 + .../src/components/settings/SshPane.tsx | 50 +++- .../sidebar/ForgetSshWorkspaceDialog.tsx | 195 +++++++++++++++ .../components/sidebar/HostRemoveDialog.tsx | 226 +++++++++++++++--- .../components/sidebar/RemoveFolderDialog.tsx | 30 ++- .../sidebar/delete-worktree-flow.ts | 35 +++ src/renderer/src/components/sidebar/index.tsx | 2 + .../ssh-host-remove-resolution.test.ts | 90 +++++++ .../sidebar/ssh-host-remove-resolution.ts | 55 +++++ .../sidebar/ssh-host-remove-workspaces.ts | 70 ++++++ .../ssh-workspace-forget-resolution.test.ts | 70 ++++++ .../ssh-workspace-forget-resolution.ts | 50 ++++ .../components/terminal-pane/TerminalPane.tsx | 14 +- .../TerminalSshReconnectOverlay.test.tsx | 33 +++ .../TerminalSshReconnectOverlay.tsx | 86 ++++--- .../terminal-pane/pty-connection.ts | 16 ++ src/renderer/src/hooks/useIpcEvents.ts | 8 + .../repos-host-identity-routing.test.ts | 82 ++++++- src/renderer/src/store/slices/repos.ts | 58 ++++- src/renderer/src/store/slices/ssh.ts | 8 + .../slices/superseded-ssh-repo-rows.test.ts | 56 +++++ .../store/slices/superseded-ssh-repo-rows.ts | 50 ++++ src/renderer/src/store/slices/ui.ts | 1 + .../src/store/slices/worktree-helpers.ts | 6 +- src/renderer/src/store/slices/worktrees.ts | 33 ++- src/renderer/src/web/web-preload-api.ts | 12 + src/shared/ssh-types.ts | 17 ++ src/shared/types.ts | 5 +- 39 files changed, 2230 insertions(+), 98 deletions(-) create mode 100644 src/main/ssh/ssh-target-readoption.test.ts create mode 100644 src/main/ssh/ssh-target-readoption.ts create mode 100644 src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx create mode 100644 src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts create mode 100644 src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts create mode 100644 src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts create mode 100644 src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts create mode 100644 src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts create mode 100644 src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts create mode 100644 src/renderer/src/store/slices/superseded-ssh-repo-rows.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e34c43821f8..c5d1c8f2a9c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -1118,6 +1118,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') + ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('projects:list') @@ -1926,6 +1927,22 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v notifyReposChanged(mainWindow) }) + // Why: forget a project on a single execution host without disturbing the + // same repo id on other hosts (local or a re-added SSH target). Used by the + // SSH-workspace forget flow when a host is removed/disconnected. + ipcMain.handle( + 'repos:removeForHost', + async (_event, args: { repoId: string; hostId: string }) => { + const hostId = normalizeExecutionHostId(args.hostId) + if (!hostId) { + throw new Error(`Invalid host ID: ${args.hostId}`) + } + store.removeProjectForHost(args.repoId, hostId) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + } + ) + ipcMain.handle( 'repos:update', ( diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 0ac0c5e0db0..1fa15be1df4 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -51,6 +51,7 @@ let currentRuntime: OrcaRuntimeService | undefined const SSH_IPC_CHANNELS = [ 'ssh:listTargets', + 'ssh:listRemovedTargetLabels', 'ssh:addTarget', 'ssh:updateTarget', 'ssh:removeTarget', @@ -695,12 +696,35 @@ export function registerSshHandlers( // ── Target CRUD ──────────────────────────────────────────────────── + // Why: SSH target add/import can re-adopt workspaces orphaned on a removed + // target id (see ssh-target-readoption). When that re-points repos, the + // renderer must refresh its repo list to surface the reattached workspaces. + function notifyReposChangedIfReadopted(): void { + if (!sshStore || sshStore.lastReadoptedRepoCount <= 0) { + return + } + sshStore.lastReadoptedRepoCount = 0 + const win = getCurrentMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('repos:changed') + } + } + ipcMain.handle('ssh:listTargets', () => { return sshStore!.listTargets() }) + ipcMain.handle('ssh:listRemovedTargetLabels', () => { + return sshStore!.listRemovedTargetLabels() + }) + ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { - return sshStore!.addTarget(args.target) + const target = sshStore!.addTarget(args.target) + // Why: re-adding a removed host can re-adopt orphaned workspaces (re-point + // repos/worktrees off the dead id). Refresh the renderer's repo list so the + // reattached workspaces move from grey ghosts back onto the live host. + notifyReposChangedIfReadopted() + return target }) ipcMain.handle( @@ -715,7 +739,9 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { - return sshStore!.importFromSshConfig(args) + const result = sshStore!.importFromSshConfig(args) + notifyReposChangedIfReadopted() + return result }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index e9f9edfcdae..43c94325789 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -7946,4 +7946,80 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(createSetupRunnerScriptMock).not.toHaveBeenCalled() }) + + describe('worktrees:forgetLocal', () => { + it('forgets a workspace pinned to a removed SSH target without touching the provider', async () => { + const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-dead', + worktreeBaseRef: null + } + const ptyProvider = {} as never + const worktreeId = 'repo-1::/workspace/feature-wt' + store.getRepo.mockReturnValue(repo) + getLocalPtyProviderMock.mockReturnValue(ptyProvider) + // Why: a removed/disconnected SSH target has no live provider; forgetLocal + // must not reach for one. + getSshGitProviderMock.mockReturnValue(undefined) + + const result = await handlers['worktrees:forgetLocal'](null, { worktreeId }) + + expect(result).toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + localProvider: ptyProvider, + onPtyStopped: clearProviderPtyStateMock + }) + expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + // The whole point: local-only cleanup never dispatches to the SSH provider. + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('throws when the repo is missing', async () => { + store.getRepo.mockReturnValue(undefined) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-gone::/workspace/feature-wt' + }) + ).rejects.toThrow(/Repo not found/) + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects forgetting a folder project root', async () => { + const repo = { + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + store.getRepo.mockReturnValue(repo) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: `${repo.id}::${repo.path}` + }) + ).rejects.toThrow(/project root workspace/) + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8b966e2b537..d3ed835646a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -931,6 +931,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:resolveMrBase') ipcMain.removeHandler('worktrees:remove') + ipcMain.removeHandler('worktrees:forgetLocal') ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') @@ -1807,6 +1808,67 @@ export function registerWorktreeHandlers( } ) + // Why: forget-locally drops a workspace from Orca without any remote Git or + // filesystem work. It exists so a workspace pinned to a removed/disconnected + // SSH target — whose provider is gone and whose `worktrees:remove` therefore + // throws at requireSshGitProvider before any cleanup runs — can still be + // cleared from the app. It never touches the remote: no worktree registration, + // no branches, no files are deleted there. + ipcMain.handle( + 'worktrees:forgetLocal', + async (_event, args: { worktreeId: string }): Promise => { + // Why: share the removal in-flight map (not a separate one) so a concurrent + // worktrees:remove and worktrees:forgetLocal on the same id cannot both + // mutate metadata. A forget takes no force/skipArchive options. + const optionsKey = 'forget-local' + const inFlight = worktreeRemovalsInFlight.get(args.worktreeId) + if (inFlight) { + if (inFlight.optionsKey === optionsKey) { + return inFlight.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + const forget = (async (): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repo = store.getRepo(repoId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + + // Why: best-effort PTY sweep. killAllProcessesForWorktree resolves + // synchronously for a dead SSH relay (the provider tombstones the lease + // and returns without awaiting the remote), so this never hangs. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) + + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return {} + })() + worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: forget }) + try { + return await forget + } finally { + if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === forget) { + worktreeRemovalsInFlight.delete(args.worktreeId) + } + } + } + ) + ipcMain.handle( 'worktrees:forceDeletePreservedBranch', async ( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d4862d9dcae..ca2248cbeaa 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3058,6 +3058,136 @@ describe('Store', () => { expect(store.getWorktreeLineage('r2::/other')).toBeDefined() }) + // ── 6b. removeProjectForHost is host-scoped ─────────────────────────── + + it('removeProjectForHost removes only the target host row for a shared repo id', async () => { + const store = await createStore() + // Same repo id on both local and an SSH host. + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt', hostId: 'local' }) + store.setWorktreeMeta('shared::/remote/repo/wt', { + displayName: 'remote-wt', + hostId: 'ssh:ssh-old' + }) + + store.removeProjectForHost('shared', 'ssh:ssh-old') + + const remaining = store.getRepos().filter((r) => r.id === 'shared') + expect(remaining).toHaveLength(1) + expect(remaining[0].path).toBe('/local/repo') + // Local worktree meta survives; the SSH host's meta is pruned. + expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() + expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() + }) + + it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + // SSH worktree meta correctly carries hostId (the normal, stamped case). + store.setWorktreeMeta('shared::/remote/repo/wt', { + displayName: 'remote-wt', + hostId: 'ssh:ssh-old' + }) + // A hostId-less meta under the same id is treated as local and left behind + // (conservative: never deletes the wrong host's meta). + store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt' }) + + store.removeProjectForHost('shared', 'ssh:ssh-old') + + expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() + expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() + }) + + it('removeProjectForHost prunes everything when the id exists on no other host', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'only', connectionId: 'ssh-x', executionHostId: 'ssh:ssh-x' })) + store.setWorktreeMeta('only::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-x' }) + + store.removeProjectForHost('only', 'ssh:ssh-x') + + expect(store.getRepo('only')).toBeUndefined() + expect(store.getWorktreeMeta('only::/repo/wt')).toBeUndefined() + }) + + // ── 6c. reassignSshTargetId re-adopts orphaned workspaces ───────────── + + it('reassignSshTargetId re-points repos and worktree metas onto the new id', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) + store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + const repo = store.getRepo('r1')! + expect(repo.connectionId).toBe('ssh-new') + expect(repo.executionHostId).toBe('ssh:ssh-new') + expect(store.getWorktreeMeta('r1::/repo/wt')!.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId leaves repos on other hosts untouched', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'local-repo', path: '/local' })) + store.addRepo( + makeRepo({ + id: 'ssh-repo', + path: '/remote', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + expect(store.getRepo('local-repo')!.connectionId).toBeUndefined() + expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new') + }) + + it('reassignSshTargetId re-points a repo that only carries connectionId (no executionHostId)', async () => { + const store = await createStore() + // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + const repo = store.getRepo('r1')! + expect(repo.connectionId).toBe('ssh-new') + // Must not stamp an executionHostId where there wasn't one. + expect(repo.executionHostId).toBeUndefined() + }) + + it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { + const store = await createStore() + // A meta on the old SSH host with no corresponding repo row — the re-point + // must still be saved, not left in memory only. + store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + expect(count).toBe(0) // no repo matched + store.flush() + + const reloaded = await createStore() + expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new') + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5bad9510e65..4ff4971933b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -82,6 +82,7 @@ import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { hardenExistingSecureFile } from '../shared/secure-file' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + type RemovedSshTargetTombstone, type SshRemotePtyLease, type SshTarget } from '../shared/ssh-types' @@ -108,6 +109,7 @@ import { normalizeExecutionHostOrder, normalizeExecutionHostId, normalizeVisibleExecutionHostIds, + toSshExecutionHostId, type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' @@ -2181,6 +2183,10 @@ function remapAcknowledgedAgentPaneKeys( // handful of Claude sessions a daemon can realistically keep alive. const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 +// Why: bound the removed-SSH-target history so remove/re-add churn can't grow +// the state file without limit. Re-adoption only needs recent removals. +const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 + function normalizeClaudeLivePtySessionIds(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -4172,30 +4178,84 @@ export class Store { // Why: presets are repo-scoped, so removing the repo means the presets // can never be referenced again — drop them with the parent. delete this.state.sparsePresetsByRepo[id] - // Clean up worktree meta for this repo + this.pruneWorktreeStateForRepo(id, null) + this.scheduleSave() + } + + // Why: the same repo id can exist on more than one execution host (local, an + // SSH target, a re-added SSH target). Forgetting one host's copy must remove + // only that host's repo row and worktree metadata — never the local or + // another host's records that happen to share the id. + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this.state.repos = this.state.repos.filter( + (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) + ) + const idStillPresent = this.state.repos.some((r) => r.id === id) + // Why: presets are repo-id-scoped (not host-scoped); only drop them once the + // last host's copy of this repo is gone, or a surviving host loses its presets. + if (!idStillPresent) { + delete this.state.sparsePresetsByRepo[id] + } + this.syncProjectHostSetupCompatibilityState() + // Why: if the id survives on another host, prune only this host's worktree + // metas; otherwise prune everything for the id (matches removeProject). + this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) + this.scheduleSave() + } + + // Clean up worktree meta, lineage, and workspace lineage for a repo id. + // When hostId is null, prune all of the repo's entries; when a hostId is + // given, prune only entries whose meta.hostId resolves to that host (a + // missing hostId is treated as local). + private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { const prefix = `${id}::` + // Why: snapshot host membership up front. Lineage pruning below checks the + // meta.hostId of worktree keys that may already have been deleted from + // worktreeMeta in the first loop, so reading hostId live would misclassify + // an SSH worktree as local once its meta is gone. + const hostMembership = new Map() + const belongsToHost = (key: string): boolean => { + if (!key.startsWith(prefix)) { + return false + } + if (hostId === null) { + return true + } + const cached = hostMembership.get(key) + if (cached !== undefined) { + return cached + } + // Why default to local: worktree metas created on/after host-ownership + // stamping carry hostId. A metas without it predates that and is treated as + // local, so a host-scoped (non-local) prune conservatively leaves it — it + // may leak a stale entry for a legacy SSH worktree sharing a repo id with a + // local repo, but it never deletes the wrong host's live meta. + const metaHostId = this.state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID + const result = metaHostId === hostId + hostMembership.set(key, result) + return result + } for (const key of Object.keys(this.state.worktreeMeta)) { - if (key.startsWith(prefix)) { + if (belongsToHost(key)) { delete this.state.worktreeMeta[key] } } for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { - if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) { + if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { delete this.state.worktreeLineageById[childId] } } for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { const childScope = parseWorkspaceKey(childKey) const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey) - if (childScope?.type === 'worktree' && childScope.worktreeId.startsWith(prefix)) { + if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] continue } - if (parentScope?.type === 'worktree' && parentScope.worktreeId.startsWith(prefix)) { + if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } - this.scheduleSave() } updateRepo( @@ -6006,6 +6066,82 @@ export class Store { } } + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return [...(this.state.removedSshTargetTombstones ?? [])] + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + const existing = this.state.removedSshTargetTombstones ?? [] + // Why: dedupe by oldTargetId so a remove/re-remove of the same id can't + // stack duplicate tombstones. Newest wins. + const filtered = existing.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + // Cap the history so pathological churn can't grow the state file unbounded. + this.state.removedSshTargetTombstones = [...filtered, tombstone].slice( + -MAX_REMOVED_SSH_TARGET_TOMBSTONES + ) + this.scheduleSave() + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + const existing = this.state.removedSshTargetTombstones + if (!existing?.some((t) => t.oldTargetId === oldTargetId)) { + return + } + this.state.removedSshTargetTombstones = existing.filter((t) => t.oldTargetId !== oldTargetId) + this.scheduleSave() + } + + /** + * Re-point every repo and worktree meta pinned to a removed SSH target id + * onto a re-added target's id, so orphaned workspaces reattach to the live + * host instead of remaining un-removable ghosts. Returns the number of repos + * re-pointed (0 when nothing referenced the old id). + */ + reassignSshTargetId(oldTargetId: string, newTargetId: string): number { + if (oldTargetId === newTargetId) { + return 0 + } + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let repoCount = 0 + for (const repo of this.state.repos) { + const matchesConnection = repo.connectionId === oldTargetId + const matchesHost = repo.executionHostId === oldHostId + if (!matchesConnection && !matchesHost) { + continue + } + if (matchesConnection) { + repo.connectionId = newTargetId + } + // Why: only rewrite executionHostId when it was actually set to the old + // SSH host. SSH repos created via addRemoteRepoFromPath leave it unset and + // derive the host from connectionId, so we must not stamp a value where + // there wasn't one. + if (matchesHost) { + repo.executionHostId = newHostId + } + repoCount++ + } + // Re-point worktree metas whose hostId pointed at the old SSH host. + let metaChanged = false + for (const meta of Object.values(this.state.worktreeMeta)) { + if (meta.hostId === oldHostId) { + meta.hostId = newHostId + metaChanged = true + } + } + // Why: repo-row rewrites can affect host-setup compatibility, but meta-only + // rewrites cannot — keep that sync under the repo gate. Persist whenever + // either repos OR metas changed, so meta-only re-points aren't lost on quit. + if (repoCount > 0) { + this.syncProjectHostSetupCompatibilityState() + } + if (repoCount > 0 || metaChanged) { + this.scheduleSave() + } + return repoCount + } + // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index f1bf80d17aa..02e69c6dc97 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshConnectionStore } from './ssh-connection-store' -import type { SshTarget } from '../../shared/ssh-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -15,6 +15,8 @@ vi.mock('./ssh-config-parser', () => ({ function createMockStore() { const targets: SshTarget[] = [] let deletedAliases: string[] = [] + const removedTombstones: RemovedSshTargetTombstone[] = [] + const reassignments: { oldTargetId: string; newTargetId: string }[] = [] return { getSshTargets: vi.fn(() => [...targets]), @@ -45,6 +47,24 @@ function createMockStore() { }), clearDeletedSshConfigAliases: vi.fn(() => { deletedAliases = [] + }), + removedTombstones, + reassignments, + getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), + addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { + const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...filtered, tombstone) + }), + removeRemovedSshTargetTombstone: vi.fn((oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + }), + reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { + reassignments.push({ oldTargetId, newTargetId }) + // Pretend one repo referenced the old id. + return 1 }) } } @@ -439,4 +459,140 @@ describe('SshConnectionStore', () => { expect(result).toHaveLength(1) }) }) + + describe('re-adoption of orphaned workspaces', () => { + it('records a tombstone when removing any user-facing target', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + + expect(mockStore.addRemovedSshTargetTombstone).toHaveBeenCalledWith( + expect.objectContaining({ + oldTargetId: 'ssh-1', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + ) + }) + + it('does not tombstone runtime-owned targets', () => { + mockStore.addSshTarget({ + id: 'runtime-ssh-abc', + label: 'VM', + host: 'vm.example.com', + port: 22, + username: 'tim', + owner: { type: 'on-demand-runtime', runtimeId: 'abc' } + }) + + sshStore.removeTarget('runtime-ssh-abc') + + expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled() + }) + + it('re-adopts orphaned repos when the same host is re-added', () => { + // Simulate a prior removal by seeding a matching tombstone. + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev', + removedAt: 1 + }) + + sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).toHaveBeenCalledTimes(1) + const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0] + expect(oldId).toBe('ssh-old') + expect(newId).toMatch(/^ssh-/) + // Re-adoption count surfaces so the IPC layer can refresh the repo list. + expect(sshStore.lastReadoptedRepoCount).toBe(1) + }) + + // Why: drive the real remove→re-add path so the tombstone carries the + // defaulted configHost (host) that buildRemovedSshTargetTombstone produces, + // rather than a hand-seeded tombstone without one. + it('re-adopts through an actual removeTarget then re-add of the same host', () => { + const added = sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + sshStore.removeTarget(added.id) + // The tombstone was built from the real target (configHost defaulted to host). + const tombstones = mockStore.getRemovedSshTargetTombstones() + expect(tombstones).toHaveLength(1) + expect(tombstones[0]).toMatchObject({ oldTargetId: added.id, configHost: 'dev.example.com' }) + + mockStore.reassignSshTargetId.mockClear() + const readded = sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id) + expect(sshStore.lastReadoptedRepoCount).toBe(1) + }) + + // A different account on the SAME host must NOT re-adopt, even though both + // manual adds default configHost to the shared hostname. + it('does not re-adopt a different account on the same host', () => { + const alice = sshStore.addTarget({ + label: 'alice', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + sshStore.removeTarget(alice.id) + mockStore.reassignSshTargetId.mockClear() + + sshStore.addTarget({ + label: 'bob', + host: 'dev.example.com', + port: 2222, + username: 'bob' + }) + + expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() + }) + + it('does not re-adopt when the re-added host identity differs', () => { + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old', + host: 'other.example.com', + port: 22, + username: 'root', + label: 'Other', + removedAt: 1 + }) + + sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() + expect(sshStore.lastReadoptedRepoCount).toBe(0) + }) + }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 975da8cdbc2..df3e6cd8fee 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -2,6 +2,10 @@ import type { Store } from '../persistence' import type { SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' +import { + buildRemovedSshTargetTombstone, + readoptOrphanedWorkspacesForTarget +} from './ssh-target-readoption' export class SshConnectionStore { constructor(private store: Store) {} @@ -10,6 +14,17 @@ export class SshConnectionStore { return this.store.getSshTargets().filter((target) => !isRuntimeOwnedSshTarget(target)) } + /** Map of removed-target id → its last known label, from the re-adoption + * tombstones. Lets the renderer show a friendly host name for a workspace + * still pinned to a target that no longer exists. */ + listRemovedTargetLabels(): Record { + const labels: Record = {} + for (const tombstone of this.store.getRemovedSshTargetTombstones()) { + labels[tombstone.oldTargetId] = tombstone.label + } + return labels + } + getTarget(id: string): SshTarget | undefined { return this.store.getSshTarget(id) } @@ -27,9 +42,17 @@ export class SshConnectionStore { // keep it — lift any tombstone so config sync stops suppressing this alias. this.reclaimAlias(full.configHost ?? full.label) this.store.addSshTarget(full) + // Why: re-adopt workspaces that were orphaned when the same host was removed + // (repos/worktrees still point at the old, now-dead target id). Track the + // count so the IPC layer knows whether to refresh the repo list. + this.lastReadoptedRepoCount = readoptOrphanedWorkspacesForTarget(this.store, full) return full } + /** Repos re-adopted by the most recent addTarget/importFromSshConfig call. + * Lets the IPC layer broadcast repos:changed only when workspaces reattached. */ + lastReadoptedRepoCount = 0 + upsertRuntimeOwnedTarget( runtimeId: string, target: Omit @@ -75,6 +98,13 @@ export class SshConnectionStore { this.store.addDeletedSshConfigAlias(alias) } } + // Why: record the removed target's host identity (for ALL user-facing + // targets, config-managed or manual) so a later re-add of the same host can + // re-adopt any workspaces orphaned on this id. Runtime-owned targets manage + // their own lifecycle and are never re-adopted. + if (target && !isRuntimeOwnedSshTarget(target)) { + this.store.addRemovedSshTargetTombstone(buildRemovedSshTargetTombstone(target, Date.now())) + } this.store.removeSshTarget(id) } @@ -90,6 +120,7 @@ export class SshConnectionStore { * manual targets. Returns the inserted and updated targets. */ importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { + let readoptedThisImport = 0 // Why: the explicit Import action re-adopts every config host, so it clears // all tombstones first. The passive on-open sync passes no flag and keeps // deleted hosts suppressed. @@ -178,10 +209,15 @@ export class SshConnectionStore { } else { const inserted: SshTarget = { ...candidate, source: 'ssh-config' } this.store.addSshTarget(inserted) + // Why: a freshly-inserted config host may be one the user removed and is + // now re-importing — re-adopt its orphaned workspaces. Updated-in-place + // targets keep their id, so their repos were never orphaned. + readoptedThisImport += readoptOrphanedWorkspacesForTarget(this.store, inserted) changed.push(inserted) } } + this.lastReadoptedRepoCount = readoptedThisImport return changed } } diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts new file mode 100644 index 00000000000..f263fe01d88 --- /dev/null +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest' +import { + buildRemovedSshTargetTombstone, + readoptOrphanedWorkspacesForTarget +} from './ssh-target-readoption' +import type { Store } from '../persistence' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' + +function makeTarget(overrides: Partial = {}): SshTarget { + return { + id: 'ssh-new', + label: 'Dev box', + host: 'dev.example.com', + port: 22, + username: 'tim', + ...overrides + } +} + +/** Minimal in-memory store exposing only what re-adoption touches. */ +function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) { + const reassigned: { oldId: string; newId: string }[] = [] + let current = [...tombstones] + const store = { + getRemovedSshTargetTombstones: () => [...current], + removeRemovedSshTargetTombstone: (oldTargetId: string) => { + current = current.filter((t) => t.oldTargetId !== oldTargetId) + }, + reassignSshTargetId: (oldTargetId: string, newTargetId: string) => { + reassigned.push({ oldId: oldTargetId, newId: newTargetId }) + return 1 + } + } as unknown as Store + return { store, reassigned, remaining: () => current } +} + +const tombstone = ( + overrides: Partial = {} +): RemovedSshTargetTombstone => ({ + oldTargetId: 'ssh-old', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev box', + removedAt: 1, + ...overrides +}) + +describe('readoptOrphanedWorkspacesForTarget', () => { + it('re-adopts on matching configHost alias', () => { + const fake = makeFakeStore([tombstone({ configHost: 'devbox', host: 'changed.example.com' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'devbox', host: 'now.example.com' }) + ) + expect(count).toBe(1) + expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) + expect(fake.remaining()).toHaveLength(0) // tombstone consumed + }) + + it('re-adopts on matching host+user+port when no alias', () => { + const fake = makeFakeStore([tombstone()]) + const count = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) + expect(count).toBe(1) + expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) + }) + + it('does not re-adopt when identity differs', () => { + const fake = makeFakeStore([tombstone({ host: 'other.example.com', username: 'root' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + expect(fake.remaining()).toHaveLength(1) // tombstone left for a future match + }) + + it('matches host/user/port case-insensitively', () => { + const fake = makeFakeStore([tombstone({ host: 'Dev.Example.COM', username: 'Tim' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(1) + }) + + it('does not match alias against a different host tuple', () => { + // Different alias AND different tuple => no match. + const fake = makeFakeStore([ + tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(0) + }) + + it('is a no-op when there are no tombstones', () => { + const fake = makeFakeStore([]) + expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toBe(0) + }) + + it('does NOT re-adopt across two different aliases that share host+user+port', () => { + // prod-deploy removed; prod-admin re-added — same box, different alias. + // These are deliberately distinct targets and must not steal workspaces. + const fake = makeFakeStore([ + tombstone({ configHost: 'prod-deploy', host: 'prod.example.com', username: 'deploy' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'prod-admin', host: 'prod.example.com', username: 'deploy' }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + expect(fake.remaining()).toHaveLength(1) // tombstone preserved + }) + + it('does NOT re-adopt a different account/port on the same host (implicit alias)', () => { + // Manual adds default configHost to host, so both carry configHost === host. + // That is NOT a real alias — matching on it alone would ignore port/username + // and reattach workspaces to the wrong SSH account on the same machine. + const fake = makeFakeStore([ + tombstone({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 2222, + username: 'bob' + }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + }) + + it('re-adopts the same account/host/port even with implicit configHost === host', () => { + const fake = makeFakeStore([ + tombstone({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ) + expect(count).toBe(1) + }) + + it('still re-adopts via tuple when the re-added target has no alias', () => { + // Manual re-add (no configHost) of a host that was config-managed: fall back + // to the tuple since one side lacks an alias to distinguish it. + const fake = makeFakeStore([ + tombstone({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: undefined, host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(1) + }) +}) + +describe('buildRemovedSshTargetTombstone', () => { + it('captures identity fields and omits configHost when absent', () => { + const t = buildRemovedSshTargetTombstone(makeTarget(), 123) + expect(t).toEqual({ + oldTargetId: 'ssh-new', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev box', + removedAt: 123 + }) + expect('configHost' in t).toBe(false) + }) + + it('includes configHost when present', () => { + const t = buildRemovedSshTargetTombstone(makeTarget({ configHost: 'devbox' }), 1) + expect(t.configHost).toBe('devbox') + }) +}) diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts new file mode 100644 index 00000000000..06b0cefd8ec --- /dev/null +++ b/src/main/ssh/ssh-target-readoption.ts @@ -0,0 +1,99 @@ +import type { Store } from '../persistence' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' + +/** + * Re-adoption of workspaces orphaned when an SSH target was removed. + * + * Repos/worktrees store only the (random) target id, so removing a target + * strands them on a dead id. When the user re-adds the same host, a fresh id is + * minted and nothing links the old workspaces to it. We bridge that gap using + * removal-time tombstones ({ oldTargetId, configHost, host, username, port }): + * on add/import we match the new target's identity to a tombstone and re-point + * every repo/worktree from the old id to the new one. + * + * Matching is intentionally strict — configHost (alias) first, then the + * host+username+port tuple — so we only auto-reattach on a confident identity + * match and never mislink two genuinely different hosts. Anything that doesn't + * match cleanly stays a ghost, handled by the forget flow instead. + */ + +type IdentityFields = Pick + +function normalize(value: string | undefined): string { + return value?.trim().toLowerCase() ?? '' +} + +function tupleKey(fields: IdentityFields): string { + return `${normalize(fields.host)}|${fields.port}|${normalize(fields.username)}` +} + +// An alias only counts as a distinguishing identity when it differs from the +// host. addTarget defaults configHost to host, so a manual add with no real +// ssh-config alias has configHost === host — that's not an alias, it's just the +// hostname, and matching on it alone would ignore port/username. +function meaningfulAlias(fields: IdentityFields): string { + const alias = normalize(fields.configHost) + return alias && alias !== normalize(fields.host) ? alias : '' +} + +function tombstoneMatches(tombstone: RemovedSshTargetTombstone, target: IdentityFields): boolean { + const targetAlias = meaningfulAlias(target) + const tombstoneAlias = meaningfulAlias(tombstone) + // Primary: matching ssh-config alias. Stable across remove/re-import. + if (targetAlias && tombstoneAlias) { + // Both carry a real alias — the alias is the identity. Different aliases + // mean deliberately distinct targets (e.g. prod-deploy vs prod-admin on the + // same box with different identity files); do NOT fall through to the tuple, + // or a second alias for the same endpoint would steal the first's workspaces. + return targetAlias === tombstoneAlias + } + // Fallback: identical host+user+port. Used when either side has no real alias + // (manual adds default configHost to host), so a different account or port on + // the same host is correctly treated as a different target. + return tupleKey(tombstone) === tupleKey(target) +} + +/** + * Re-point orphaned repos/worktrees onto `newTarget` if a removed target with + * the same host identity is tombstoned. Consumes the matching tombstone(s). + * Returns the number of repos re-adopted (0 when there was nothing to adopt). + */ +export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshTarget): number { + const tombstones = store.getRemovedSshTargetTombstones() + if (tombstones.length === 0) { + return 0 + } + let readopted = 0 + for (const tombstone of tombstones) { + // Why: a re-added target can't share the id of one that still exists, but + // guard anyway so we never re-point a live target onto itself. + if (tombstone.oldTargetId === newTarget.id) { + store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + continue + } + if (!tombstoneMatches(tombstone, newTarget)) { + continue + } + readopted += store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + // Consume the tombstone whether or not it re-pointed anything: the host has + // returned, so the record has served its purpose. + store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + } + return readopted +} + +/** Build a tombstone from a target about to be removed. */ +export function buildRemovedSshTargetTombstone( + target: SshTarget, + removedAt: number +): RemovedSshTargetTombstone { + return { + oldTargetId: target.id, + ...(target.configHost ? { configHost: target.configHost } : {}), + host: target.host, + port: target.port, + username: target.username, + label: target.label, + removedAt + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 10687c01a29..35ac8802402 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -859,6 +859,9 @@ export type PreloadApi = { kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> remove: (args: { repoId: string }) => Promise + // Forget a project on one execution host only, leaving the same repo id on + // other hosts (local or a re-added SSH target) intact. + removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string @@ -1059,6 +1062,9 @@ export type PreloadApi = { force?: boolean skipArchive?: boolean }) => Promise + // Forget a workspace from Orca only — no remote Git/filesystem work. Used + // for workspaces pinned to a removed/disconnected SSH host. + forgetLocal: (args: { worktreeId: string }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string branchName: string @@ -2811,6 +2817,9 @@ export type PreloadApi = { } ssh: { listTargets: () => Promise + // Removed-target id → last known label, for showing a friendly host name on + // workspaces still pinned to a target that no longer exists. + listRemovedTargetLabels: () => Promise> addTarget: (args: { target: Omit }) => Promise updateTarget: (args: { id: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a24404831a..b3b6a2978db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -520,6 +520,8 @@ const api = { remove: (args) => ipcRenderer.invoke('repos:remove', args), + removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), update: (args) => ipcRenderer.invoke('repos:update', args), @@ -652,6 +654,8 @@ const api = { remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + forceDeletePreservedBranch: (args) => ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), @@ -3834,6 +3838,9 @@ const api = { ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + listRemovedTargetLabels: (): Promise> => + ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + addTarget: (args: { target: Omit }): Promise => ipcRenderer.invoke('ssh:addTarget', args), diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 1af4ba26f08..534383be3b5 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -12,6 +12,10 @@ import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' import { getEditingTargetForSshTarget } from './ssh-target-draft' import { buildSshTargetSavePayload } from './ssh-target-save-payload' +import { HostRemoveDialog } from '../sidebar/HostRemoveDialog' +import { resolveSshHostRemoval } from '../sidebar/ssh-host-remove-resolution' +import { getAllWorktreesFromState } from '@/store/selectors' +import { toSshExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' export { getSshPaneSearchEntries } from './ssh-search' @@ -28,6 +32,14 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [testingIds, setTestingIds] = useState>(new Set()) + // Why: when a target still has workspaces, route removal through the shared + // workspace-aware HostRemoveDialog (same as the sidebar) instead of the plain + // confirm, so the user chooses to delete or keep them rather than silently + // orphaning them. + const [hostRemoveTarget, setHostRemoveTarget] = useState<{ + targetId: string + label: string + } | null>(null) const mountedRef = useMountedRef() const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) @@ -122,6 +134,25 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { } } + // Route removal through the workspace-aware dialog when the target still owns + // workspaces; otherwise use the plain confirm (which also ends remote PTYs). + const requestRemoveTarget = ( + target: { id: string; label: string }, + requestPlainRemove: (target: { id: string; label: string }) => void + ): void => { + const resolution = resolveSshHostRemoval({ + targetId: target.id, + repos: useAppStore.getState().repos, + worktrees: getAllWorktreesFromState(useAppStore.getState()), + sshConnectionStates: useAppStore.getState().sshConnectionStates + }) + if (resolution.workspaceCount > 0) { + setHostRemoveTarget({ targetId: target.id, label: target.label }) + return + } + requestPlainRemove(target) + } + const handleRemove = async (id: string): Promise => { try { await removeSshTargetWithBestEffortCleanup(window.api.ssh, id) @@ -366,7 +397,9 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { onResetRelay={(id) => requestResetRelay({ id, label: target.label })} onTest={handleTest} onEdit={handleEdit} - onRemove={(id) => requestRemove({ id, label: target.label })} + onRemove={(id) => + requestRemoveTarget({ id, label: target.label }, requestRemove) + } /> ))}

@@ -385,6 +418,21 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { )} + + {hostRemoveTarget ? ( + { + if (!open) { + setHostRemoveTarget(null) + void loadTargets() + } + }} + hostId={toSshExecutionHostId(hostRemoveTarget.targetId)} + label={hostRemoveTarget.label} + target={{ kind: 'ssh', targetId: hostRemoveTarget.targetId }} + /> + ) : null}
) } diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx new file mode 100644 index 00000000000..b8067195b51 --- /dev/null +++ b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { Loader2, Server, ServerOff } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { runWorktreeDeleteWithToast } from './delete-worktree-flow' +import type { SshWorkspaceForgetResolution } from './ssh-workspace-forget-resolution' + +type ForgetSshWorkspaceModalData = { + worktreeId: string + displayName: string + resolution: SshWorkspaceForgetResolution +} + +function isForgetModalData(data: unknown): data is ForgetSshWorkspaceModalData { + if (!data || typeof data !== 'object') { + return false + } + const candidate = data as Partial + return typeof candidate.worktreeId === 'string' && candidate.resolution != null +} + +export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { + const modalData = useAppStore((s) => s.modalData) + const closeModal = useAppStore((s) => s.closeModal) + const hostLabel = useAppStore((s) => { + const resolution = isForgetModalData(s.modalData) ? s.modalData.resolution : null + const targetId = resolution && resolution.kind !== 'not-ssh' ? resolution.targetId : undefined + if (!targetId) { + return '' + } + // Prefer the live label, then the removed target's last known label (ghost + // host), then the raw id as a last resort. + return s.sshTargetLabels.get(targetId) ?? s.removedSshTargetLabels.get(targetId) ?? targetId + }) + const [busy, setBusy] = useState(null) + const mountedRef = useMountedRef() + + if (!isForgetModalData(modalData)) { + return null + } + const { worktreeId, displayName, resolution } = modalData + const canReconnect = resolution.kind === 'disconnected' + + const done = (): void => { + if (mountedRef.current) { + setBusy(null) + closeModal() + } + } + + // Reconnect the SSH target, then run the normal remote worktree removal. + const handleReconnectAndDelete = async (): Promise => { + if (resolution.kind !== 'disconnected') { + return + } + setBusy('reconnect') + try { + await window.api.ssh.connect({ targetId: resolution.targetId }) + } catch (err) { + if (mountedRef.current) { + setBusy(null) + } + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.reconnectFailed', + 'Reconnection failed' + ) + ) + return + } + // Close before the delete toast fires so the two don't overlap. + closeModal() + void runWorktreeDeleteWithToast(worktreeId, displayName) + if (mountedRef.current) { + setBusy(null) + } + } + + // Remove Orca's records only — never touches remote files, worktrees, or branches. + const handleForget = async (): Promise => { + setBusy('forget') + try { + const result = await useAppStore + .getState() + .removeWorktree(worktreeId, false, { mode: 'forget-local' }) + if (!result.ok) { + toast.error(result.error) + if (mountedRef.current) { + setBusy(null) + } + return + } + done() + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + if (mountedRef.current) { + setBusy(null) + } + } + } + + const forgetDescription = translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.forgetBody', + 'Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.', + { host: hostLabel } + ) + + return ( + (!open ? closeModal() : undefined)}> + + + + + {translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.title', + 'Delete “{{name}}”?', + { + name: displayName + } + )} + + + {canReconnect + ? translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody', + 'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.' + ) + : translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody', + '{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.', + { host: hostLabel } + )} + + + +
+ + {hostLabel} +
+ + {/* Why: the ghost-host description already states files are untouched, so + only repeat the reassurance on the reconnect (disconnected) path. */} + {canReconnect ? ( +

{forgetDescription}

+ ) : null} + + + + + {canReconnect ? ( + + ) : null} + +
+
+ ) +} + +export default ForgetSshWorkspaceDialog diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index 97592d73fe0..97135f97eeb 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react' -import { Loader2 } from 'lucide-react' +import React, { useMemo, useState } from 'react' +import { ChevronDown, Loader2 } from 'lucide-react' import { toast } from 'sonner' +import { cn } from '@/lib/utils' import { Dialog, DialogContent, @@ -12,12 +13,14 @@ import { import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../shared/execution-host' -import { parseExecutionHostId } from '../../../../shared/execution-host' import { removeSshTargetWithBestEffortCleanup } from '../settings/ssh-target-remove' import { clearHostRename } from './host-rename-remove' import type { HostRemovalTarget } from './host-rename-remove' +import { resolveSshHostRemoval } from './ssh-host-remove-resolution' +import { clearSshHostWorkspaces } from './ssh-host-remove-workspaces' type HostRemoveDialogProps = { open: boolean @@ -35,8 +38,35 @@ export function HostRemoveDialog({ target }: HostRemoveDialogProps): React.JSX.Element { const [busy, setBusy] = useState(false) + // Why: removing the host only (keeping workspaces) is the safe, reversible + // default. Deleting the remote workspaces is destructive, so it lives behind + // an Advanced disclosure and must be opted into explicitly. + const [advancedOpen, setAdvancedOpen] = useState(false) + const [deleteWorkspaces, setDeleteWorkspaces] = useState(false) const mountedRef = useMountedRef() + const repos = useAppStore((s) => s.repos) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + + // Resolve which workspaces live on the target so the dialog can offer to + // delete/forget them alongside the host. + const sshResolution = useMemo(() => { + if (target.kind !== 'ssh') { + return null + } + return resolveSshHostRemoval({ + targetId: target.targetId, + repos, + worktrees: getAllWorktreesFromState({ worktreesByRepo }), + sshConnectionStates + }) + }, [target, repos, worktreesByRepo, sshConnectionStates]) + + const workspaceCount = sshResolution?.workspaceCount ?? 0 + const hasWorkspaces = workspaceCount > 0 + const isConnected = sshResolution?.isConnected ?? false + // Why: dropping a host should also drop its now-orphaned label override so a // future host reusing the same id doesn't inherit a stale rename. const dropOverridesForHost = (): void => { @@ -46,7 +76,7 @@ export function HostRemoveDialog({ }) } - const handleRemoveSsh = async (targetId: string): Promise => { + const removeSshTarget = async (targetId: string): Promise => { await removeSshTargetWithBestEffortCleanup(window.api.ssh, targetId) // Why: clear deferred reconnect metadata so focused SSH tabs stop retrying // the deleted target — mirrors the SSH settings pane removal flow. @@ -64,14 +94,37 @@ export function HostRemoveDialog({ onOpenChange(false) } - const confirm = async (): Promise => { - if (target.kind === 'runtime') { - handleRemoveRuntime(target.environmentId) + const runSshRemoval = async (): Promise => { + if (target.kind !== 'ssh') { return } setBusy(true) try { - await handleRemoveSsh(target.targetId) + if (deleteWorkspaces && sshResolution) { + // Connected → real remote removal; offline/ghost → local forget. + const { failedIds } = await clearSshHostWorkspaces( + sshResolution, + isConnected ? 'delete-remote' : 'forget-local' + ) + // Why: don't remove the SSH target (and report success) while some of its + // workspaces failed to clear — that would strand ghost rows behind a + // now-gone host. Surface the failure and keep the target so the user can + // retry or resolve the blocking workspace first. + if (failedIds.length > 0) { + if (mountedRef.current) { + setBusy(false) + } + toast.error( + translate( + 'auto.components.sidebar.HostRemoveDialog.workspacesFailed', + 'Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.', + { count: failedIds.length } + ) + ) + return + } + } + await removeSshTarget(target.targetId) if (mountedRef.current) { onOpenChange(false) } @@ -96,7 +149,47 @@ export function HostRemoveDialog({ } } - const isRuntime = parseExecutionHostId(hostId)?.kind === 'runtime' + const workspaceCountLabel = + workspaceCount === 1 + ? translate('auto.components.sidebar.HostRemoveDialog.oneWorkspace', '1 workspace') + : translate( + 'auto.components.sidebar.HostRemoveDialog.manyWorkspaces', + '{{count}} workspaces', + { + count: workspaceCount + } + ) + + const description = + target.kind === 'runtime' + ? translate( + 'auto.components.sidebar.HostRemoveDialog.4d5e6f7a8b', + 'This opens the Orca servers settings where you can remove this server.' + ) + : hasWorkspaces + ? translate( + 'auto.components.sidebar.HostRemoveDialog.hostHasWorkspacesDefault', + 'Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.', + { value0: label, value1: workspaceCountLabel } + ) + : translate( + 'auto.components.sidebar.HostRemoveDialog.5e6f7a8b9c', + 'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.' + ) + + // The destructive opt-in wording depends on whether we delete remote files or + // only forget Orca's records (offline/ghost host). + const deleteOptionLabel = isConnected + ? translate( + 'auto.components.sidebar.HostRemoveDialog.alsoDeleteRemote', + 'Also delete these {{value0}} on {{value1}}', + { value0: workspaceCountLabel, value1: label } + ) + : translate( + 'auto.components.sidebar.HostRemoveDialog.alsoForgetLocal', + 'Also remove these {{value0}} from Orca', + { value0: workspaceCountLabel } + ) return ( @@ -111,33 +204,106 @@ export function HostRemoveDialog({ } )} - - {isRuntime - ? translate( - 'auto.components.sidebar.HostRemoveDialog.4d5e6f7a8b', - 'This opens the Orca servers settings where you can remove this server.' - ) - : translate( - 'auto.components.sidebar.HostRemoveDialog.5e6f7a8b9c', - 'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.' - )} - + {description} - - + + {/* Advanced disclosure: deleting the host's workspaces is destructive, so + it's opt-in and hidden by default. Only shown when there are any. + Matches the New Workspace composer's Advanced header + switch. */} + {target.kind === 'ssh' && hasWorkspaces ? ( +
+ +
+
+
+ + + {deleteOptionLabel} + + {isConnected + ? translate( + 'auto.components.sidebar.HostRemoveDialog.alsoDeleteRemoteHint', + 'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.' + ) + : translate( + 'auto.components.sidebar.HostRemoveDialog.alsoForgetLocalHint', + 'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.' + )} + + +
+
+
+
+ ) : null} + + + {target.kind === 'runtime' ? ( + + ) : ( + + )}
diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 8e37ecf0aed..376ead35e57 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -21,6 +21,22 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : '' + // Why: for an SSH project the files live on the remote host's disk, not the + // user's — "still on your disk" would be misleading. Name the host (using the + // removed-target label when it's a ghost) so the user knows where it remains + // and that re-adding that host recovers it. + const sshHostLabel = useAppStore((s) => { + const connectionId = s.repos.find((r) => r.id === repoId)?.connectionId?.trim() + if (!connectionId) { + return null + } + return ( + s.sshTargetLabels.get(connectionId) ?? + s.removedSshTargetLabels.get(connectionId) ?? + connectionId + ) + }) + const handleConfirm = useCallback(() => { if (repoId) { void removeProject(repoId) @@ -50,10 +66,16 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { 'This only removes' )}{' '} {displayName}{' '} - {translate( - 'auto.components.sidebar.RemoveFolderDialog.8c097ef04e', - 'from Orca. It is still on your disk.' - )} + {sshHostLabel + ? translate( + 'auto.components.sidebar.RemoveFolderDialog.fromOrcaSsh', + 'from Orca. Its files stay on {{value0}} — re-add that SSH host to recover it.', + { value0: sshHostLabel } + ) + : translate( + 'auto.components.sidebar.RemoveFolderDialog.8c097ef04e', + 'from Orca. It is still on your disk.' + )} diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index dc81f9a22ca..cac60ca3727 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -5,6 +5,8 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' +import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { isPathInsideOrEqual, normalizeRuntimePathForComparison @@ -249,6 +251,39 @@ export function runWorktreeDelete(worktreeId: string): void { return } state.clearWorktreeDeleteState(worktreeId) + + // Why: a workspace on a removed/disconnected SSH host cannot go through the + // normal remote removal — its provider is gone, so worktrees:remove throws + // before any cleanup. Route to a dialog that offers reconnect-and-delete + // (when the target still exists) or a local-only forget. + // + // Skip this on paired web/mobile clients: SSH targets/labels/connection state + // are desktop-only, so those clients have empty sshTargetLabels and would + // misclassify every SSH repo as a ghost, routing to a forget dialog whose + // local-only backend is unavailable there. Their normal worktree.rm RPC path + // already handles the delete against the desktop runtime. + const repo = state.repos.find((entry) => entry.id === target.repoId) ?? null + const sshResolution = isPairedWebClientWindow() + ? { kind: 'not-ssh' as const } + : resolveSshWorkspaceForget({ + repo, + sshConnectionStates: state.sshConnectionStates, + sshTargetLabels: state.sshTargetLabels + }) + if (sshResolution.kind === 'ghost' || sshResolution.kind === 'disconnected') { + // Why no lineage-children warning here (unlike the normal path below): + // forget-local is metadata-only and per-worktree, so it can't fail on a + // still-registered child the way a remote git removal would. Any descendants + // live on the same ghost host and remain independently visible/forgettable — + // they are not orphaned unrecoverably. + state.openModal('forget-ssh-workspace', { + worktreeId, + displayName: target.displayName, + resolution: sshResolution + }) + return + } + const hasLineageChildren = getWorkspaceDeleteLineage(target, state.allWorktrees(), state.worktreeLineageById).descendants .length > 0 diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 577eaa929ae..b224e594277 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -21,6 +21,7 @@ const WorktreeMetaDialog = lazyWithRetry(() => import('./WorktreeMetaDialog')) const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog')) const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog')) const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) +const ForgetSshWorkspaceDialog = lazyWithRetry(() => import('./ForgetSshWorkspaceDialog')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -207,6 +208,7 @@ function Sidebar({ {activeModal === 'confirm-remove-folder' ? : null} {activeModal === 'worktree-visibility' ? : null} {activeModal === 'confirm-orca-yaml-hooks' ? : null} + {activeModal === 'forget-ssh-workspace' ? : null} {sidebarOpen ? ( { + return new Map([[id, { targetId: id, status: 'connected', error: null, reconnectAttempt: 0 }]]) +} + +describe('resolveSshHostRemoval', () => { + const repos = [ + { id: 'repo-a', connectionId: 'ssh-1' }, + { id: 'repo-b', connectionId: 'ssh-1' }, + { id: 'repo-local', connectionId: null } + ] + const worktrees = [ + { id: 'repo-a::/wt/main', repoId: 'repo-a', isMainWorktree: true }, + { id: 'repo-a::/wt/feature', repoId: 'repo-a', isMainWorktree: false }, + { id: 'repo-b::/wt/x', repoId: 'repo-b', isMainWorktree: false }, + { id: 'repo-local::/wt/y', repoId: 'repo-local', isMainWorktree: false } + ] + + it('collects non-main worktrees and root repos on the target', () => { + const result = resolveSshHostRemoval({ + targetId: 'ssh-1', + repos, + worktrees, + sshConnectionStates: new Map() + }) + expect(result.workspaceWorktreeIds.sort()).toEqual( + ['repo-a::/wt/feature', 'repo-b::/wt/x'].sort() + ) + expect(result.hostRepoIds.sort()).toEqual(['repo-a', 'repo-b']) + // 2 child worktrees + 2 root repos. + expect(result.workspaceCount).toBe(4) + expect(result.isConnected).toBe(false) + }) + + it('ignores repos and worktrees on other hosts', () => { + const result = resolveSshHostRemoval({ + targetId: 'ssh-1', + repos, + worktrees, + sshConnectionStates: new Map() + }) + expect(result.workspaceWorktreeIds).not.toContain('repo-local::/wt/y') + expect(result.hostRepoIds).not.toContain('repo-local') + }) + + it('reports connected when the target relay is connected', () => { + const result = resolveSshHostRemoval({ + targetId: 'ssh-1', + repos, + worktrees, + sshConnectionStates: connected('ssh-1') + }) + expect(result.isConnected).toBe(true) + }) + + it('reports zero workspaces for a target nothing points at', () => { + const result = resolveSshHostRemoval({ + targetId: 'ssh-unused', + repos, + worktrees, + sshConnectionStates: new Map() + }) + expect(result.workspaceCount).toBe(0) + expect(result.hostRepoIds).toEqual([]) + }) + + it('dedupes duplicate repo and worktree rows so the count is not inflated', () => { + const result = resolveSshHostRemoval({ + // Store transiently holds duplicate rows (e.g. mid host merge). + targetId: 'ssh-1', + repos: [ + { id: 'repo-a', connectionId: 'ssh-1' }, + { id: 'repo-a', connectionId: 'ssh-1' } // duplicate repo row + ], + worktrees: [ + { id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false }, + { id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false }, // duplicate + { id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false } // triplicate + ], + sshConnectionStates: new Map() + }) + expect(result.hostRepoIds).toEqual(['repo-a']) + expect(result.workspaceWorktreeIds).toEqual(['repo-a::/wt/x']) + // 1 root repo + 1 unique child worktree. + expect(result.workspaceCount).toBe(2) + }) +}) diff --git a/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts b/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts new file mode 100644 index 00000000000..4949ab3db6c --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts @@ -0,0 +1,55 @@ +import type { Repo, Worktree } from '../../../../shared/types' +import type { SshConnectionState } from '../../../../shared/ssh-types' + +/** + * Classifies what removing an SSH target means for the workspaces still on it, + * so the host-removal dialog can offer the right choice: + * - connected → can delete host AND its remote worktrees (real git removal) + * - offline → target still configured but not connected; delete would need a + * reconnect, so default to forgetting the workspaces locally + * - (no workspaces) → plain host removal, no workspace decision needed + */ +export type SshHostRemoveResolution = { + targetId: string + /** Ids of non-main worktrees on this host that a remote delete would remove. */ + workspaceWorktreeIds: string[] + /** Repo ids whose main/root workspace lives on this host. */ + hostRepoIds: string[] + workspaceCount: number + isConnected: boolean +} + +export function resolveSshHostRemoval(args: { + targetId: string + repos: readonly Pick[] + worktrees: readonly Pick[] + sshConnectionStates: ReadonlyMap +}): SshHostRemoveResolution { + const hostRepoIds = [ + ...new Set( + args.repos + .filter((repo) => repo.connectionId?.trim() === args.targetId) + .map((repo) => repo.id) + ) + ] + const hostRepoIdSet = new Set(hostRepoIds) + // Why: dedupe by id — the store can transiently hold duplicate worktree rows + // (e.g. mid host merge), and a doubled row must not inflate the count shown to + // the user or cause a worktree to be removed twice. + const workspaceWorktreeIds = [ + ...new Set( + args.worktrees + .filter((worktree) => hostRepoIdSet.has(worktree.repoId) && !worktree.isMainWorktree) + .map((worktree) => worktree.id) + ) + ] + const isConnected = args.sshConnectionStates.get(args.targetId)?.status === 'connected' + return { + targetId: args.targetId, + workspaceWorktreeIds, + hostRepoIds, + // A repo's main/root workspace counts too — removing the host drops it. + workspaceCount: workspaceWorktreeIds.length + hostRepoIds.length, + isConnected + } +} diff --git a/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts b/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts new file mode 100644 index 00000000000..87145f0e4de --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts @@ -0,0 +1,70 @@ +import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { useAppStore } from '@/store' +import type { SshHostRemoveResolution } from './ssh-host-remove-resolution' + +export type ClearSshHostWorkspacesResult = { + /** Worktree/repo removals that reported failure ({ ok: false }). Non-empty + * means the SSH target should not be reported as fully removed. */ + failedIds: string[] +} + +/** + * Clears the workspaces on an SSH host that is being removed, before the target + * itself is deleted. Two modes: + * - 'delete-remote': the host is connected, so run the normal remote removal + * for each worktree (deletes the remote git worktree) and the host-scoped + * project removal for each root repo. + * - 'forget-local': the host is offline/gone, so only clear Orca's records — + * no remote files, worktrees, or branches are touched. + * + * Worktrees are removed before their root repos so a root removal never races a + * still-registered child. Failures are collected (not thrown) so the caller can + * decide whether to proceed with target removal. + */ +export async function clearSshHostWorkspaces( + resolution: SshHostRemoveResolution, + mode: 'delete-remote' | 'forget-local' +): Promise { + const store = useAppStore.getState() + const forgetLocalOnly = mode === 'forget-local' + const failedIds: string[] = [] + + for (const worktreeId of resolution.workspaceWorktreeIds) { + // Why: sequential, not parallel — deletes on the same repo contend on git + // ref locks, and forget is cheap enough that ordering keeps failures legible. + const result = await store.removeWorktree( + worktreeId, + false, + forgetLocalOnly ? { mode: 'forget-local' } : undefined + ) + if (!result.ok) { + failedIds.push(worktreeId) + } + } + + // Why: removeProject purges renderer state and (in main) is host-scoped. Pass + // the explicit SSH host id so a repo id shared with the local host resolves to + // this host's row instead of falling back to the focused host — otherwise the + // wrong (local) project could be removed and the SSH ghost left behind. For an + // offline/ghost host this hits the local backend path (no live runtime target), + // clearing Orca's records without a successful remote call. + const hostId = toSshExecutionHostId(resolution.targetId) + for (const repoId of resolution.hostRepoIds) { + try { + await store.removeProject(repoId, { hostId }) + } catch { + failedIds.push(repoId) + } + // Why: removeProject swallows its own errors and returns void, so the + // try/catch above can't observe a failure. Verify the host's repo row is + // actually gone; if it lingers, the removal did not succeed. + const stillPresent = useAppStore + .getState() + .repos.some((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) + if (stillPresent && !failedIds.includes(repoId)) { + failedIds.push(repoId) + } + } + + return { failedIds } +} diff --git a/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts new file mode 100644 index 00000000000..85f79192509 --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' +import type { SshConnectionState } from '../../../../shared/ssh-types' + +function stateMap( + entries: Record +): Map { + return new Map( + Object.entries(entries).map(([id, status]) => [ + id, + { targetId: id, status, error: null, reconnectAttempt: 0 } + ]) + ) +} + +describe('resolveSshWorkspaceForget', () => { + it('returns not-ssh for a repo with no connectionId', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: null }, + sshConnectionStates: new Map(), + sshTargetLabels: new Map() + }) + expect(result.kind).toBe('not-ssh') + }) + + it('returns not-ssh for a runtime-owned target', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: 'runtime-ssh-abc' }, + sshConnectionStates: new Map(), + sshTargetLabels: new Map() + }) + expect(result.kind).toBe('not-ssh') + }) + + it('returns ghost when the target is no longer configured', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: 'ssh-dead' }, + sshConnectionStates: new Map(), + sshTargetLabels: new Map() // ssh-dead not present -> ghost + }) + expect(result).toEqual({ kind: 'ghost', targetId: 'ssh-dead' }) + }) + + it('returns connected when the configured target is connected', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: 'ssh-live' }, + sshConnectionStates: stateMap({ 'ssh-live': 'connected' }), + sshTargetLabels: new Map([['ssh-live', 'Live']]) + }) + expect(result).toEqual({ kind: 'connected', targetId: 'ssh-live' }) + }) + + it('returns disconnected when configured but not connected', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: 'ssh-live' }, + sshConnectionStates: stateMap({ 'ssh-live': 'error' }), + sshTargetLabels: new Map([['ssh-live', 'Live']]) + }) + expect(result).toEqual({ kind: 'disconnected', targetId: 'ssh-live', status: 'error' }) + }) + + it('defaults to disconnected status when configured target has no live state', () => { + const result = resolveSshWorkspaceForget({ + repo: { connectionId: 'ssh-live' }, + sshConnectionStates: new Map(), + sshTargetLabels: new Map([['ssh-live', 'Live']]) + }) + expect(result).toEqual({ kind: 'disconnected', targetId: 'ssh-live', status: 'disconnected' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts new file mode 100644 index 00000000000..72243c5f370 --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.ts @@ -0,0 +1,50 @@ +import type { Repo } from '../../../../shared/types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +/** + * How a workspace on an SSH host should be deleted, given the host's live + * state. Drives whether the delete flow can go straight through the normal + * remote removal, must offer a reconnect-first choice, or can only forget the + * workspace from Orca because the SSH target is gone entirely. + */ +export type SshWorkspaceForgetResolution = + | { kind: 'not-ssh' } + // Target exists and the relay is connected — normal remote removal works. + | { kind: 'connected'; targetId: string } + // Target still configured but not connected — offer Reconnect & Delete plus + // a local-only forget fallback. + | { kind: 'disconnected'; targetId: string; status: SshConnectionState['status'] } + // Target was removed; only a project-only "ghost" host remains. Reconnect is + // impossible, so forget-from-Orca is the only path. + | { kind: 'ghost'; targetId: string } + +export function resolveSshWorkspaceForget(args: { + repo: Pick | null | undefined + sshConnectionStates: ReadonlyMap + // Keys are configured SSH target ids (targets that still exist in settings). + sshTargetLabels: ReadonlyMap +}): SshWorkspaceForgetResolution { + const connectionId = args.repo?.connectionId?.trim() + // Why: runtime-owned (ephemeral-VM) SSH targets manage their own lifecycle and + // are never user-facing ghosts, so they take the normal delete path. + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + return { kind: 'not-ssh' } + } + + const isConfigured = args.sshTargetLabels.has(connectionId) + const status = args.sshConnectionStates.get(connectionId)?.status + + // Why: a target the user removed leaves repos pinned to a dead id with no + // configured target — the grey ghost host. Reconnect can never succeed, so + // the only escape is to forget it from Orca. + if (!isConfigured) { + return { kind: 'ghost', targetId: connectionId } + } + + if (status === 'connected') { + return { kind: 'connected', targetId: connectionId } + } + + return { kind: 'disconnected', targetId: connectionId, status: status ?? 'disconnected' } +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index ee8053809dc..e73d1964619 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -313,9 +313,19 @@ export default function TerminalPane({ ) const sshReconnectTargetLabel = useAppStore((store) => sshReconnectTargetId - ? (store.sshTargetLabels.get(sshReconnectTargetId) ?? sshReconnectTargetId) + ? (store.sshTargetLabels.get(sshReconnectTargetId) ?? + // Fall back to the removed target's last known label (ghost host) before + // the raw id, so a removed host shows its name instead of ssh--. + store.removedSshTargetLabels.get(sshReconnectTargetId) ?? + sshReconnectTargetId) : '' ) + // Why: the target was removed entirely (a ghost) when it's no longer a known + // SSH target. Reconnecting to it can only fail ("SSH target not found"), so + // the overlay must offer to remove the workspace instead of Connect. + const sshReconnectTargetRemoved = useAppStore((store) => + sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false + ) useVisibleTerminalTabClaim({ isVisible, tabId }) @@ -2972,6 +2982,8 @@ export default function TerminalPane({ targetId={sshReconnectTargetId} targetLabel={sshReconnectTargetLabel} status={sshReconnectStatus} + targetRemoved={sshReconnectTargetRemoved} + worktreeId={worktreeId} /> ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index 328dbec1283..4653b88e46c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -13,12 +13,20 @@ const toastMocks = vi.hoisted(() => ({ error: vi.fn() })) +const deleteFlowMocks = vi.hoisted(() => ({ + runWorktreeDelete: vi.fn() +})) + vi.mock('sonner', () => ({ toast: { error: toastMocks.error } })) +vi.mock('../sidebar/delete-worktree-flow', () => ({ + runWorktreeDelete: deleteFlowMocks.runWorktreeDelete +})) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => fallback.replace('{{value0}}', values?.value0 ?? '') @@ -39,6 +47,7 @@ describe('TerminalSshReconnectOverlay', () => { beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) toastMocks.error.mockReset() + deleteFlowMocks.runWorktreeDelete.mockReset() }) afterEach(() => { @@ -101,6 +110,30 @@ describe('TerminalSshReconnectOverlay', () => { expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled() }) + it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => { + const connect = vi.fn().mockResolvedValue(undefined) + installSshConnect(connect) + const user = userEvent.setup() + + render( + + ) + + expect(screen.getByText('SSH host removed')).toBeInTheDocument() + // No Connect button — reconnect is impossible for a removed target. + expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Remove workspace' })) + expect(deleteFlowMocks.runWorktreeDelete).toHaveBeenCalledWith('repo::/work/wt') + expect(connect).not.toHaveBeenCalled() + }) + it('publishes the returned SSH state so deferred terminal reattach can resume', async () => { const connectedState: SshConnectionState = { targetId: 'ssh-target-1', diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index 8d6b013b01f..b2be21b6627 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -6,11 +6,16 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' +import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' type TerminalSshReconnectOverlayProps = { targetId: string targetLabel: string status: SshConnectionStatus + // The SSH target was removed entirely — reconnect is impossible, so offer to + // remove the workspace instead of a Connect button that can only fail. + targetRemoved?: boolean + worktreeId?: string } // Why: relay deployment/reconnect are host-driven transient states; the @@ -63,13 +68,16 @@ function messageForStatus(status: SshConnectionStatus, targetLabel: string): str export function TerminalSshReconnectOverlay({ targetId, targetLabel, - status + status, + targetRemoved = false, + worktreeId }: TerminalSshReconnectOverlayProps): React.JSX.Element { const [connecting, setConnecting] = useState(false) const mountedRef = useMountedRef() const setSshConnectionState = useAppStore((store) => store.setSshConnectionState) const isConnecting = connecting || isConnectingStatus(status) - const showConnect = canConnectStatus(status) + // Why: a removed target can never reconnect, so never offer Connect for it. + const showConnect = !targetRemoved && canConnectStatus(status) const handleConnect = useCallback(async () => { if (isConnecting) { @@ -115,13 +123,23 @@ export function TerminalSshReconnectOverlay({
- {translate( - 'auto.components.terminal.pane.TerminalSshReconnectOverlay.title', - 'SSH connection required' - )} + {targetRemoved + ? translate( + 'auto.components.terminal.pane.TerminalSshReconnectOverlay.removedTitle', + 'SSH host removed' + ) + : translate( + 'auto.components.terminal.pane.TerminalSshReconnectOverlay.title', + 'SSH connection required' + )}
- {messageForStatus(status, targetLabel)} + {targetRemoved + ? translate( + 'auto.components.terminal.pane.TerminalSshReconnectOverlay.removedBody', + 'The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.' + ) + : messageForStatus(status, targetLabel)}
@@ -130,26 +148,40 @@ export function TerminalSshReconnectOverlay({ {targetLabel}
- + {targetRemoved ? ( + + ) : ( + + )}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2a6a968bf2f..2e984d7865d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -9,6 +9,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' @@ -5546,6 +5547,21 @@ export function connectPanePty( // because the SSH provider isn't registered until after connect succeeds. if (connectionId) { const storeState = useAppStore.getState() + // Why: the SSH target was removed entirely (a ghost workspace). Reattaching + // can only fail with "SSH target not found", which surfaces a red "file an + // issue" banner for what is an expected user action. Skip reattach — the + // terminal overlay already shows a "host removed" state with a remove + // action. Runtime-owned targets aren't user-managed, so they're exempt. + // A target map that exists but omits this id means the target was removed. + // (Guard the map's presence for minimal test stubs that omit it; an absent + // map is "not hydrated", not "target gone".) + if ( + !isRuntimeOwnedSshTargetId(connectionId) && + storeState.sshTargetLabels instanceof Map && + !storeState.sshTargetLabels.has(connectionId) + ) { + return + } const restoredLeafSessionId = deps.restoredLeafId && deps.restoredPtyIdByLeafId ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index af6295f2923..3b327b924d4 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2516,6 +2516,14 @@ export function useIpcEvents(): void { try { const targets = await window.api.ssh.listTargets() useAppStore.getState().setSshTargetsMetadata(targets) + // Why: ghost-host UI (removed target still referenced by a workspace) + // shows a friendly name from the removal tombstones instead of the raw id. + try { + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + } catch { + // Best-effort — a missing map just falls back to the raw target id. + } for (const target of targets) { const state = await window.api.ssh.getState({ targetId: target.id }) if (state) { diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 22690115f32..ebdc85fd471 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -26,6 +26,7 @@ const remoteDuplicate: Repo = { } const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() const ptyKill = vi.fn() @@ -59,6 +60,7 @@ function projectHostSetup(overrides: Pick): P beforeEach(() => { clearRuntimeCompatibilityCacheForTests() reposRemove.mockReset() + reposRemoveForHost.mockReset() reposUpdate.mockReset() reposReorder.mockReset() ptyKill.mockReset() @@ -71,6 +73,7 @@ beforeEach(() => { api: { repos: { remove: reposRemove, + removeForHost: reposRemoveForHost, update: reposUpdate, reorder: reposReorder }, @@ -260,11 +263,88 @@ describe('repo slice host identity routing', () => { expect(store.getState().projects).toEqual([ expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) ]) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'same-repo' }) + // Why: the id also exists on runtime:env-1, so the local-side removal must be + // host-scoped in main to avoid deleting the other host's persisted repo row. + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(ptyKill).toHaveBeenCalledWith('local-pty') expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') }) + it('removeProject with an explicit hostId routes to that host, not the focused one', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-explicit-host', + ok: true, + result: { status: 'removed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const localWorktree = makeWorktree({ id: 'same-repo::/local/wt', repoId: 'same-repo' }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + // Focus is local (no active runtime env). Without deriving the target from the + // explicit hostId, this would route to the focused (local) host and delete the + // wrong row. It must target runtime:env-1 via that host's RPC. + store.setState({ + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] } + }) + + await store.getState().removeProject('same-repo', { hostId: 'runtime:env-1' }) + + // Routes to the runtime host's repo.rm (not the local removeForHost/remove), + // and the local row is left intact. + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + expect(reposRemoveForHost).not.toHaveBeenCalled() + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos).toEqual([localDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + }) + + it('removeProject of an SSH host row routes local even when a runtime is focused', async () => { + // Regression: removing an SSH host's repo (explicit ssh hostId) must NOT route + // repo.rm to the focused runtime env. settingsForRepoOwner clears the focused + // runtime for SSH owners, so removal stays on the host-scoped local path. + const sshDuplicate: Repo = { + id: 'same-repo', + path: '/home/orca/project', + displayName: 'SSH', + badgeColor: '#222', + addedAt: 3, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + const sshWorktree = makeWorktree({ + id: 'same-repo::/home/orca/wt', + repoId: 'same-repo', + hostId: 'ssh:ssh-1' + }) + const store = createTestStore() + // A runtime env is focused, but the row being removed is SSH-owned. + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, sshDuplicate], + worktreesByRepo: { 'same-repo': [sshWorktree] } + }) + + await store.getState().removeProject('same-repo', { hostId: 'ssh:ssh-1' }) + + // Host-scoped local removal (id also exists on local), never the runtime RPC. + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:ssh-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.rm' }) + ) + expect(store.getState().repos).toEqual([localDuplicate]) + }) + it('removes a runtime duplicate without purging legacy local worktrees', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-duplicate-remove', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index da0cb011d56..55429bb6e28 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -48,6 +48,7 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' +import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows' import { splitRepoReorderByHost } from './repo-reorder-host-split' import { omitSparsePresetsForRepos } from './sparse-presets' import { @@ -74,7 +75,8 @@ import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, toRuntimeExecutionHostId, - toSshExecutionHostId + toSshExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -1038,8 +1040,12 @@ async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[] } } -function settingsForRepoOwner(state: Pick, repoId: string) { - const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId +) { + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings, hostId }) if (!repo) { return state.settings } @@ -1371,7 +1377,9 @@ export type RepoSlice = { groupId: string | null, order?: number ) => Promise - removeProject: (projectId: string) => Promise + // options.hostId disambiguates which host's row to remove when the same repo + // id exists on multiple hosts; without it the focused host is assumed. + removeProject: (projectId: string, options?: { hostId?: ExecutionHostId }) => Promise updateProject: (projectId: string, updates: ProjectUpdate) => Promise updateRepo: (projectId: string, updates: RepoUpdate) => Promise setActiveRepo: (projectId: string | null) => void @@ -1408,18 +1416,26 @@ export const createRepoSlice: StateCreator = (set, return } set((s) => { - const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + // Why: after re-adoption re-points a repo onto a re-added SSH target, the + // per-host merge leaves the stale row on the old (removed) target id — a + // ghost a terminal pane can bind to and fail with "SSH target not found". + // Drop rows on unknown SSH targets that a live-host sibling supersedes. + const prunedRepos = pruneSupersededSshRepoRows( + reconciledRepos, + new Set(s.sshTargetLabels.keys()) + ) + const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, fetched: projectCompatibility, - repos: reconciledRepos, + repos: prunedRepos, hostId }) return { - repos: reconciledRepos, + repos: prunedRepos, ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, @@ -2539,9 +2555,15 @@ export const createRepoSlice: StateCreator = (set, } }, - removeProject: async (projectId) => { + removeProject: async (projectId, options) => { try { - const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + // Why: pass an explicit hostId (e.g. when removing an SSH host's root repo) + // so a duplicate id across hosts resolves to the intended row instead of + // falling back to the focused host. + const ownerRepo = findRepoForHost(get().repos, projectId, { + settings: get().settings, + hostId: options?.hostId + }) if (!ownerRepo) { return } @@ -2556,9 +2578,23 @@ export const createRepoSlice: StateCreator = (set, runtimeOwnedSshTargetIds: [ownerRepo.connectionId as string] }) } - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + // Why: derive the runtime target from the owner's own settings, passing the + // explicit options.hostId so a duplicate repo id across hosts resolves to the + // intended row. settingsForRepoOwner clears the focused runtime for SSH/local + // owners (routing local) and pins runtime owners to their environment, so an + // SSH host removal never routes repo.rm to the focused runtime. + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, options?.hostId)) + // Why: the same repo id can exist on multiple hosts (local + an SSH target, + // or a re-added SSH target). Main's repos:remove is repo-id-only and would + // delete every host's row. Scope the local-side removal to the owning host + // so a cross-host duplicate id keeps its other rows. + const idExistsOnOtherHost = get().repos.some( + (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId + ) await (target.kind === 'local' - ? window.api.repos.remove({ repoId: projectId }) + ? idExistsOnOtherHost + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) + : window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(projectId) diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index b7d2b9eb445..8b98b77b623 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -33,6 +33,10 @@ export type SshSlice = { /** Maps target IDs to their user-facing labels. Populated during hydration * so components can look up labels without per-component IPC calls. */ sshTargetLabels: Map + /** Maps REMOVED target IDs to their last known label (from re-adoption + * tombstones). Lets ghost-host UI show a friendly name instead of the raw id + * for a workspace still pinned to a deleted target. */ + removedSshTargetLabels: Map remoteWorkspaceHydratedTargetIds: Set remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] @@ -50,6 +54,7 @@ export type SshSlice = { detectedPortsByConnection: Record setSshConnectionState: (targetId: string, state: SshConnectionState) => void setSshTargetLabels: (labels: Map) => void + setRemovedSshTargetLabels: (labels: Record) => void setSshTargetsMetadata: (targets: Pick[]) => void clearRemovedSshTargetState: (targetId: string) => void markRemoteWorkspaceHydrated: (targetId: string) => void @@ -65,6 +70,7 @@ export type SshSlice = { export const createSshSlice: StateCreator = (set) => ({ sshConnectionStates: new Map(), sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), remoteWorkspaceHydratedTargetIds: new Set(), remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], @@ -90,6 +96,8 @@ export const createSshSlice: StateCreator = (set) => }), setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }), + setRemovedSshTargetLabels: (labels) => + set({ removedSshTargetLabels: new Map(Object.entries(labels)) }), setSshTargetsMetadata: (targets) => set((s) => { if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts new file mode 100644 index 00000000000..64299ac3704 --- /dev/null +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows' +import type { Repo } from '../../../../shared/types' + +function repo(overrides: Partial): Repo { + return { + id: 'r', + path: '/p', + displayName: 'r', + badgeColor: '#000', + addedAt: 1, + ...overrides + } +} + +describe('pruneSupersededSshRepoRows', () => { + it('drops a stale dead-SSH row when a live-host sibling shares the id', () => { + // The re-adoption leftover: same id on a dead target + a live one. + const repos = [ + repo({ id: 'shared', connectionId: 'ssh-dead' }), + repo({ id: 'shared', connectionId: 'ssh-live' }) + ] + const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) + expect(result.map((r) => r.connectionId)).toEqual(['ssh-live']) + }) + + it('KEEPS a lone project-only ghost (no live sibling) so it can still be forgotten', () => { + const repos = [repo({ id: 'ghost', connectionId: 'ssh-dead' })] + const result = pruneSupersededSshRepoRows(repos, new Set()) + expect(result).toHaveLength(1) + expect(result[0].connectionId).toBe('ssh-dead') + }) + + it('drops a dead-SSH row superseded by a LOCAL sibling', () => { + // A repo id on both local and a removed SSH host: the local row is the live + // sibling, so the SSH ghost is a re-adoption/duplicate leftover → drop it. + const repos = [repo({ id: 'shared' }), repo({ id: 'shared', connectionId: 'ssh-dead' })] + const result = pruneSupersededSshRepoRows(repos, new Set()) + expect(result.map((r) => r.connectionId ?? 'local')).toEqual(['local']) + }) + + it('leaves live SSH rows untouched', () => { + const repos = [repo({ id: 'a', connectionId: 'ssh-live' }), repo({ id: 'b' })] + const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) + expect(result).toHaveLength(2) + }) + + it('never prunes runtime-owned SSH rows', () => { + const repos = [ + repo({ id: 'shared', connectionId: 'runtime-ssh-abc' }), + repo({ id: 'shared', connectionId: 'ssh-live' }) + ] + const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) + expect(result).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts new file mode 100644 index 00000000000..c97d9bb96e0 --- /dev/null +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -0,0 +1,50 @@ +import type { Repo } from '../../../../shared/types' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +/** + * Drops repo rows left stranded on a removed SSH target after re-adoption + * re-pointed the same repo onto a re-added host. + * + * Re-adoption (main) rewrites a repo's connectionId from the old (dead) target + * id to the new one, but the renderer's per-host repo merge preserves rows on + * "other hosts" — and the dead SSH host still looks like another host, so its + * stale row lingers. A terminal pane bound to that ghost row then fails with + * "SSH target not found". + * + * A row is superseded (and pruned) only when ALL hold: + * - it targets an SSH connection that is NOT a currently-known target, and + * - the same repo id also exists on a DIFFERENT host that IS known/live. + * + * This never removes a legitimate lone project-only ghost host (its repo id + * exists only on the dead host, so there is no live sibling to supersede it) — + * those are kept on purpose so the sidebar can still surface and forget them. + */ +export function pruneSupersededSshRepoRows( + repos: readonly Repo[], + knownSshTargetIds: ReadonlySet +): Repo[] { + const isDeadSshRow = (repo: Repo): boolean => { + const connectionId = repo.connectionId?.trim() + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + return false + } + return !knownSshTargetIds.has(connectionId) + } + + // Repo ids that have at least one row on a known/live host (local or a live + // SSH/runtime target). Only these can supersede a dead-host sibling. + const idsWithLiveHost = new Set() + for (const repo of repos) { + if (!isDeadSshRow(repo)) { + idsWithLiveHost.add(repo.id) + } + } + + return repos.filter((repo) => { + if (!isDeadSshRow(repo)) { + return true + } + // Keep a lone ghost (no live sibling); drop only a superseded leftover. + return !idsWithLiveHost.has(repo.id) + }) +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 3a0205cbe40..04748058e16 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -745,6 +745,7 @@ export type UISlice = { | 'create-worktree' | 'edit-meta' | 'delete-worktree' + | 'forget-ssh-workspace' | 'confirm-add-project-from-folder' | 'confirm-non-git-folder' | 'confirm-remove-folder' diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index bbc5575ae48..85efa92dcc1 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -177,7 +177,11 @@ export type WorktreeSlice = { prefetchWorktreeCreateBase: (repoId: string, baseBranch?: string) => Promise removeWorktree: ( worktreeId: string, - force?: boolean + force?: boolean, + // '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' } ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 0eac7020639..36d70b5d319 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3144,7 +3144,8 @@ export const createWorktreeSlice: StateCreator }) }, - removeWorktree: async (worktreeId, force) => { + removeWorktree: async (worktreeId, force, options) => { + const forgetLocalOnly = options?.mode === 'forget-local' set((s) => ({ deleteStateByWorktreeId: { ...s.deleteStateByWorktreeId, @@ -3157,22 +3158,30 @@ export const createWorktreeSlice: StateCreator })) try { - const repoIdForTrust = getRepoIdFromWorktreeId(worktreeId) - const trustDecision = await ensureHooksConfirmed(get(), repoIdForTrust, 'archive') - const skipArchive = trustDecision === 'skip' + // Why: forget-local touches no remote, so there is no archive hook to run + // and no need to prompt for hook trust. + const skipArchive = forgetLocalOnly + ? true + : (await ensureHooksConfirmed(get(), getRepoIdFromWorktreeId(worktreeId), 'archive')) === + 'skip' const worktreeBeforeRemoval = get() .allWorktrees() .find((entry) => entry.id === worktreeId) + // Why: forget-local always clears Orca's own records via the local IPC + // handler regardless of the workspace's execution host — the whole point + // is that the remote (SSH relay / runtime) is gone or unreachable. const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) - const removalResult = await (target.kind === 'local' - ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) - : callRuntimeRpc( - target, - 'worktree.rm', - { worktree: toRuntimeWorktreeSelector(worktreeId), force, runHooks: !skipArchive }, - { timeoutMs: 60_000 } - )) + const removalResult = await (forgetLocalOnly + ? window.api.worktrees.forgetLocal({ worktreeId }) + : target.kind === 'local' + ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) + : callRuntimeRpc( + target, + 'worktree.rm', + { worktree: toRuntimeWorktreeSelector(worktreeId), force, runHooks: !skipArchive }, + { timeoutMs: 60_000 } + )) const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() if (worktreeDisplayName) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index eb46da67724..30b97677b04 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1127,6 +1127,12 @@ function createReposApi(): NonNullable['repos']> { await callRuntimeResult('repo.rm', { repo: repoId }) invalidateRuntimeWorktreeCaches() }, + // Why: host-scoped forget targets a disconnected/removed SSH host owned by + // the desktop app. A paired web client talks to a single Orca runtime and + // has no ghost-host state to reconcile. + removeForHost: () => { + throw new Error('Forgetting a host is unavailable in paired web clients.') + }, reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), update: async ({ repoId, updates }) => (await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo, @@ -1290,6 +1296,11 @@ function createWorktreesApi(): NonNullable['worktrees']> { force }) }, + // Why: forget-locally clears a workspace pinned to a disconnected/removed + // SSH host on the desktop app; a paired web client has no such ghost state. + forgetLocal: () => { + throw new Error('Forgetting a workspace is unavailable in paired web clients.') + }, forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead }) => callRuntimeResult('worktree.forceDeleteBranch', { worktree: toRuntimeWorktreeSelector(worktreeId), @@ -2608,6 +2619,7 @@ function createPtyApi(): NonNullable['pty']> { function createSshApi(): NonNullable['ssh']> { return { listTargets: () => Promise.resolve([]), + listRemovedTargetLabels: () => Promise.resolve({}), addTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), updateTarget: () => diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index a6b4fa55e8d..8627f485bb3 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -51,6 +51,23 @@ export type SshTarget = { systemSshConnectionReuse?: boolean } +/** Identity of a removed SSH target, recorded so that re-adding the same host + * can re-point orphaned repos/worktrees from the old (deleted) target id to + * the new one. Repos store only the target id, so without this record the old + * workspaces are stranded on a dead id when the target is removed. */ +export type RemovedSshTargetTombstone = { + /** The id the removed target had — what orphaned repos/worktrees still point at. */ + oldTargetId: string + /** ssh-config alias, if any — the most stable re-adoption key. */ + configHost?: string + host: string + port: number + username: string + label: string + /** ms epoch when the target was removed, for pruning old tombstones. */ + removedAt: number +} + export type SavedPortForward = { localPort: number remoteHost: string diff --git a/src/shared/types.ts b/src/shared/types.ts index 98749e1012f..bcf20f6c183 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import type { ExecutionHostId } from './execution-host' -import type { SshRemotePtyLease, SshTarget } from './ssh-types' +import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' import type { GitHubProjectSettings } from './github-project-types' @@ -3515,6 +3515,9 @@ export type PersistedState = { * matching ~/.ssh/config host on the next sync so a deleted host does not * reappear. Cleared for an alias when the user re-adds it or re-adopts config. */ deletedSshConfigAliases: string[] + /** Identity records for removed SSH targets. Lets a re-added host re-adopt + * workspaces that were orphaned on the old target id. Pruned by age/count. */ + removedSshTargetTombstones?: RemovedSshTargetTombstone[] sshRemotePtyLeases: SshRemotePtyLease[] /** Daemon session ids of live local Claude launches. Seeds the Claude * live-PTY gate on startup so an early OAuth refresh cannot rotate the From dfc12f2cf622681a9329691a17ae8153b71da3c3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:10:22 -0700 Subject: [PATCH 053/152] fix(watcher): isolate @parcel/watcher in a forked process so native crashes can't kill the app (#7547) (#7757) --- config/electron-builder.config.cjs | 1 + .../scripts/electron-builder-config.test.mjs | 8 + electron.vite.config.ts | 3 + src/main/ipc/filesystem-watcher.ts | 23 +- src/main/ipc/parcel-watcher-process-entry.ts | 194 ++++++++ src/main/ipc/parcel-watcher-process.test.ts | 204 ++++++++ src/main/ipc/parcel-watcher-process.ts | 320 ++++++++++++ tools/repro-watcher-crash-7547/.gitignore | 1 + tools/repro-watcher-crash-7547/README.md | 50 ++ tools/repro-watcher-crash-7547/child.cjs | 470 ++++++++++++++++++ .../repro-watcher-crash-7547/fixed-child.cjs | 334 +++++++++++++ tools/repro-watcher-crash-7547/run.cjs | 74 +++ 12 files changed, 1676 insertions(+), 6 deletions(-) create mode 100644 src/main/ipc/parcel-watcher-process-entry.ts create mode 100644 src/main/ipc/parcel-watcher-process.test.ts create mode 100644 src/main/ipc/parcel-watcher-process.ts create mode 100644 tools/repro-watcher-crash-7547/.gitignore create mode 100644 tools/repro-watcher-crash-7547/README.md create mode 100644 tools/repro-watcher-crash-7547/child.cjs create mode 100644 tools/repro-watcher-crash-7547/fixed-child.cjs create mode 100644 tools/repro-watcher-crash-7547/run.cjs diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 51d2a77106a..a5a8042def5 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -106,6 +106,7 @@ module.exports = { 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/computer-sidecar.js', + 'out/main/parcel-watcher-process-entry.js', 'out/main/chunks/**', 'resources/**', 'node_modules/ws/**', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3f318219eeb..46298b376df 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -69,6 +69,14 @@ describe('electron-builder config', () => { ) }) + // Why: without the unpacked entry the watcher client silently falls back to + // in-process @parcel/watcher, reintroducing the #7547 main-process crash. + it('unpacks the forked parcel-watcher process entry', () => { + expect(electronBuilderConfig.asarUnpack).toEqual( + expect.arrayContaining(['out/main/parcel-watcher-process-entry.js']) + ) + }) + it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ca69fc8a7cd..b308601ee24 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -182,6 +182,9 @@ export default defineConfig({ 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), 'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'), + // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults + // can't take down the main process (issue #7547). + 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), // Why: electron-vite cleans out/main in dev. The dev CLI imports // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 51cbfbaefc6..56ab2aa2981 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -13,6 +13,7 @@ import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' +import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' // Why: high-churn directories are suppressed at the native watcher level so // events never leave the OS/daemon. This list is separate from the File // Explorer display filter (which only hides rows). Directories like `dist` @@ -278,10 +279,6 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { // ── Watcher creation ───────────────────────────────────────────────── async function createWatcher(rootKey: string, rootPath: string): Promise { - // Why: @parcel/watcher is a native module that may not load in all - // environments. Dynamic import keeps the require() lazy. - const watcher = await import('@parcel/watcher') - const root: WatchedRoot = { subscription: null!, listeners: new Map(), @@ -303,7 +300,11 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { if (err) { @@ -334,7 +335,13 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { + // The watcher process crashed and this root was resubscribed; events + // during the gap are lost, so hand the renderer an overflow refresh. + root.batch.overflowed = true + scheduleBatchFlush(rootKey, root) + } ) // Why: if the error callback already fired and cleaned up watchedRoots @@ -929,6 +936,10 @@ export async function closeAllWatchers(): Promise { } watchedRoots.clear() await Promise.allSettled(Array.from(pendingLocalUnsubscribes)) + // Why: with every local subscription released, drop the forked watcher + // process outright — process death frees any remaining native handles + // without running watcher.node's crash-prone async teardown in this process. + disposeWatcherProcess() // Why: remote watchers are tracked separately from local @parcel/watcher // subscriptions. Without cleaning them up here, their unwatch callbacks diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts new file mode 100644 index 00000000000..03676721df2 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -0,0 +1,194 @@ +// Forked (ELECTRON_RUN_AS_NODE) child that hosts all local @parcel/watcher +// subscriptions. Why: watcher.node has native teardown races that fail-fast +// the hosting process (issue #7547, 0xc0000409 on Windows; same class as +// #5377/#6635). Running the native module here turns a watcher fault into a +// contained child crash the host can recover from, instead of killing the app +// and every agent session in it. +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as ParcelWatcher from '@parcel/watcher' + +export type WatcherProcessEvent = { + type: 'create' | 'update' | 'delete' + path: string +} + +export type WatcherProcessSubscribeOptions = { + ignore?: string[] + backend?: string +} + +export type HostToWatcherMessage = + | { op: 'subscribe'; id: number; dir: string; opts: WatcherProcessSubscribeOptions } + | { op: 'unsubscribe'; id: number } + +export type WatcherToHostMessage = + | { op: 'subscribed'; id: number } + | { op: 'subscribe-failed'; id: number; message: string } + | { op: 'events'; id: number; events: WatcherProcessEvent[] } + | { op: 'watch-error'; id: number; message: string } + | { op: 'unsubscribed'; id: number } + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +// Canary self-check. Why: @parcel/watcher can wedge silently — a lock-order +// inversion between Debounce::notify (debounce mutex → watcher mutex) and +// Watcher teardown (watcher mutex → debounce mutex in ~Watcher) deadlocks the +// single process-wide debounce thread, after which every subscription still +// acks but no events are ever delivered. The canary watches a private temp +// dir and touches a file in it; consecutive missed deliveries mean event +// delivery is dead, so exit and let the host respawn a fresh process. +const CANARY_INTERVAL_MS = 10_000 +const CANARY_EVENT_TIMEOUT_MS = 5_000 +const CANARY_MAX_MISSES = 2 + +async function startCanary(hasLiveSubscriptions: () => boolean): Promise { + let canaryDir: string + let lastEventAt = 0 + try { + canaryDir = mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) + const watcher = await import('@parcel/watcher') + // Why: pin the Windows backend like the main subscriptions do, so the + // canary never probes for Watchman. + const opts = ( + process.platform === 'win32' ? { backend: 'windows' } : {} + ) as ParcelWatcher.Options + await watcher.subscribe( + canaryDir, + (err) => { + if (!err) { + lastEventAt = Date.now() + } + }, + opts + ) + } catch (err) { + process.stderr.write(`[parcel-watcher-process] canary unavailable: ${errorMessage(err)}\n`) + return + } + process.on('exit', () => { + try { + rmSync(canaryDir, { recursive: true, force: true }) + } catch { + // Temp dir cleanup is best-effort. + } + }) + + let misses = 0 + setInterval(() => { + // An idle watcher process can't wedge anything visible; only probe while + // roots are subscribed so an idle child doesn't restart pointlessly. + if (!hasLiveSubscriptions()) { + misses = 0 + return + } + const probedAt = Date.now() + try { + writeFileSync(join(canaryDir, 'canary.txt'), String(probedAt)) + } catch { + return + } + setTimeout(() => { + if (lastEventAt >= probedAt) { + misses = 0 + return + } + misses++ + if (misses >= CANARY_MAX_MISSES) { + process.stderr.write( + '[parcel-watcher-process] event delivery wedged (canary starved); restarting watcher process\n' + ) + process.exit(2) + } + }, CANARY_EVENT_TIMEOUT_MS) + }, CANARY_INTERVAL_MS) +} + +function main(): void { + const send = (message: WatcherToHostMessage): void => { + try { + process.send?.(message) + } catch { + // Host is gone; the disconnect handler below exits this process. + } + } + + // Subscribe promises are kept (not just subscriptions) so an unsubscribe + // that races a still-crawling subscribe awaits it instead of leaking the + // native handle — on Windows a leaked handle keeps the worktree dir locked. + const subscriptions = new Map>() + + const handleSubscribe = async ( + id: number, + dir: string, + opts: WatcherProcessSubscribeOptions + ): Promise => { + try { + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + send({ op: 'watch-error', id, message: errorMessage(err) }) + return + } + if (events.length > 0) { + send({ + op: 'events', + id, + events: events.map((event) => ({ type: event.type, path: event.path })) + }) + } + }, + opts as ParcelWatcher.Options + ) + send({ op: 'subscribed', id }) + return subscription + } catch (err) { + subscriptions.delete(id) + send({ op: 'subscribe-failed', id, message: errorMessage(err) }) + return null + } + } + + const handleUnsubscribe = async (id: number): Promise => { + const pending = subscriptions.get(id) + subscriptions.delete(id) + try { + const subscription = await pending + await subscription?.unsubscribe() + } catch (err) { + process.stderr.write( + `[parcel-watcher-process] unsubscribe ${id} failed: ${errorMessage(err)}\n` + ) + } + send({ op: 'unsubscribed', id }) + } + + process.on('message', (message: HostToWatcherMessage) => { + if (!message || typeof message !== 'object') { + return + } + if (message.op === 'subscribe') { + subscriptions.set(message.id, handleSubscribe(message.id, message.dir, message.opts)) + return + } + if (message.op === 'unsubscribe') { + void handleUnsubscribe(message.id) + } + }) + + void startCanary(() => subscriptions.size > 0) + + // Why: if the host dies (or kills us during shutdown), exit immediately — + // process death releases every native watcher handle without running the + // crash-prone napi teardown at all. + process.on('disconnect', () => { + process.exit(0) + }) +} + +main() diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts new file mode 100644 index 00000000000..cb5caf2eaec --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -0,0 +1,204 @@ +// Regression tests for issue #7547: local @parcel/watcher subscriptions run +// in a forked watcher process, and a native watcher crash must be contained — +// respawn, resubscribe, notify interruption — instead of killing the app. +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { forkMock, existsSyncMock, parcelSubscribeMock } = vi.hoisted(() => ({ + forkMock: vi.fn(), + existsSyncMock: vi.fn(), + parcelSubscribeMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) +vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock })) + +import { + disposeWatcherProcess, + resetWatcherProcessForTest, + subscribeViaWatcherProcess +} from './parcel-watcher-process' + +type SentMessage = { op: string; id: number; dir?: string } + +class FakeChild extends EventEmitter { + connected = true + sent: SentMessage[] = [] + stderr = new EventEmitter() + kill = vi.fn(() => { + this.connected = false + }) + send = vi.fn((message: SentMessage) => { + this.sent.push(message) + return true + }) +} + +function currentChild(): FakeChild { + const result = forkMock.mock.results.at(-1) + if (!result) { + throw new Error('fork was not called') + } + return result.value as FakeChild +} + +function ackSubscribe(child: FakeChild, index = -1): number { + const message = child.sent.filter((m) => m.op === 'subscribe').at(index) + if (!message) { + throw new Error('no subscribe message sent') + } + child.emit('message', { op: 'subscribed', id: message.id }) + return message.id +} + +describe('subscribeViaWatcherProcess', () => { + beforeEach(() => { + resetWatcherProcessForTest() + // Why: the client deliberately runs in-process under vitest; these tests + // exercise the forked-process mode, so hide the vitest marker. + vi.stubEnv('VITEST', '') + existsSyncMock.mockReturnValue(true) + forkMock.mockImplementation(() => new FakeChild()) + }) + + afterEach(() => { + disposeWatcherProcess() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('subscribes through the watcher process and forwards events', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, { ignore: ['**/.git'] }) + const child = currentChild() + expect(child.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo' }) + const id = ackSubscribe(child) + await promise + + const events = [{ type: 'update', path: '/repo/a.txt' }] + child.emit('message', { op: 'events', id, events }) + expect(callback).toHaveBeenCalledWith(null, events) + }) + + it('forwards watcher errors to the callback', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}) + const child = currentChild() + const id = ackSubscribe(child) + await promise + + child.emit('message', { op: 'watch-error', id, message: 'boom' }) + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) + }) + + it('rejects the subscribe when the watcher process reports failure', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const child = currentChild() + const id = child.sent[0].id + child.emit('message', { op: 'subscribe-failed', id, message: 'Error opening directory' }) + await expect(promise).rejects.toThrow('Error opening directory') + }) + + it('resolves unsubscribe on the child ack', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + const id = ackSubscribe(child) + const subscription = await promise + + const unsubPromise = subscription.unsubscribe() + expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) + child.emit('message', { op: 'unsubscribed', id }) + await expect(unsubPromise).resolves.toBeUndefined() + }) + + it('resolves a pending unsubscribe when the child dies', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const subscription = await promise + + const unsubPromise = subscription.unsubscribe() + child.connected = false + child.emit('exit', 3221226505, null) + await expect(unsubPromise).resolves.toBeUndefined() + }) + + it('respawns after a crash, resubscribes, and reports the interruption', async () => { + const callback = vi.fn() + const onInterruption = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}, onInterruption) + const first = currentChild() + const id = ackSubscribe(first) + await promise + + // Simulate the 0xc0000409 fail-fast: the watcher process dies. + first.connected = false + first.emit('exit', 3221226505, null) + + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', id, dir: '/repo' }) + expect(onInterruption).not.toHaveBeenCalled() + + second.emit('message', { op: 'subscribed', id }) + expect(onInterruption).toHaveBeenCalledTimes(1) + + const events = [{ type: 'create', path: '/repo/b.txt' }] + second.emit('message', { op: 'events', id, events }) + expect(callback).toHaveBeenCalledWith(null, events) + }) + + it('completes an in-flight subscribe across a crash-respawn', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + expect(first.sent[0].op).toBe('subscribe') + + first.connected = false + first.emit('exit', 3221226505, null) + + const second = currentChild() + ackSubscribe(second) + await expect(promise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('disables watching after repeated crashes instead of fork-bombing', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}) + ackSubscribe(currentChild()) + await promise + + for (let crash = 0; crash < 2; crash++) { + const child = currentChild() + child.connected = false + child.emit('exit', 3221226505, null) + ackSubscribe(currentChild()) + } + expect(forkMock).toHaveBeenCalledTimes(3) + + const last = currentChild() + last.connected = false + last.emit('exit', 3221226505, null) + + expect(forkMock).toHaveBeenCalledTimes(3) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('crashed repeatedly') }), + [] + ) + }) + + it('uses the in-process watcher under vitest', async () => { + vi.stubEnv('VITEST', 'true') + const unsubscribe = vi.fn().mockResolvedValue(undefined) + parcelSubscribeMock.mockResolvedValue({ unsubscribe }) + const callback = vi.fn() + + const subscription = await subscribeViaWatcherProcess('/repo', callback, { + ignore: ['**/.git'] + }) + expect(forkMock).not.toHaveBeenCalled() + expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', callback, { ignore: ['**/.git'] }) + await subscription.unsubscribe() + expect(unsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts new file mode 100644 index 00000000000..25e52794a8d --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.ts @@ -0,0 +1,320 @@ +// Host side of the out-of-process @parcel/watcher (see +// parcel-watcher-process-entry.ts). Why: native watcher teardown races +// fail-fast the hosting process (issue #7547), so local subscriptions live in +// a disposable forked child; when it crashes the host respawns it and +// resubscribes every live root, and callers are told via onInterruption so +// they can refresh state that changed during the gap. +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type * as ParcelWatcher from '@parcel/watcher' +import type { + HostToWatcherMessage, + WatcherProcessEvent, + WatcherProcessSubscribeOptions, + WatcherToHostMessage +} from './parcel-watcher-process-entry' + +export type { WatcherProcessEvent, WatcherProcessSubscribeOptions } + +export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void +export type WatcherProcessSubscription = { unsubscribe(): Promise } + +// Why: a crash loop (e.g. a root that faults the native watcher on every +// resubscribe) must degrade to "file watching disabled" rather than fork-bomb. +const CRASH_WINDOW_MS = 30_000 +const MAX_CRASHES_PER_WINDOW = 3 + +type SubscriptionRecord = { + id: number + dir: string + opts: WatcherProcessSubscribeOptions + callback: WatcherProcessCallback + onInterruption?: () => void + pendingSubscribe?: { resolve: () => void; reject: (err: Error) => void } +} + +let child: ChildProcess | null = null +let nextSubscriptionId = 1 +let crashTimes: number[] = [] +let shutdownRequested = false +let loggedInProcessFallback = false +const records = new Map() +const pendingUnsubscribes = new Map void>() + +function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { + try { + return require('electron').app ?? null + } catch { + return null + } +} + +function getWatcherProcessEntryPath(): string { + const app = loadElectronApp() + const appPath = app?.getAppPath() ?? process.cwd() + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must be forked from app.asar.unpacked. + const basePath = app?.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') +} + +function shouldRunInProcess(entryPath: string): boolean { + // Why: vitest suites mock '@parcel/watcher' at the module level; a forked + // child would bypass those mocks (and could execute a stale build output), + // so tests keep the historical in-process path. + if (process.env.VITEST) { + return true + } + if (existsSync(entryPath)) { + return false + } + if (!loggedInProcessFallback) { + loggedInProcessFallback = true + console.warn( + `[parcel-watcher-process] entry not found at ${entryPath}; using in-process watcher (no crash isolation)` + ) + } + return true +} + +async function subscribeInProcess( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions +): Promise { + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + dir, + callback as ParcelWatcher.SubscribeCallback, + opts as ParcelWatcher.Options + ) + return { unsubscribe: () => subscription.unsubscribe() } +} + +function inCrashCooldown(): boolean { + const now = Date.now() + crashTimes = crashTimes.filter((time) => now - time < CRASH_WINDOW_MS) + return crashTimes.length >= MAX_CRASHES_PER_WINDOW +} + +function sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void { + try { + proc.send(message) + } catch { + // Channel already closed — the child's exit handler recovers. + } +} + +function ensureWatcherProcess(): ChildProcess | null { + if (shutdownRequested) { + return null + } + if (child?.connected) { + return child + } + if (inCrashCooldown()) { + return null + } + let proc: ChildProcess + try { + proc = fork(getWatcherProcessEntryPath(), [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + } catch (err) { + console.error('[parcel-watcher-process] failed to fork watcher process:', err) + return null + } + proc.stderr?.on('data', (chunk: Buffer) => { + console.error('[parcel-watcher-process]', String(chunk).trimEnd()) + }) + proc.on('message', (message) => { + if (child === proc) { + handleChildMessage(message as WatcherToHostMessage) + } + }) + proc.on('error', () => handleChildGone(proc)) + proc.on('exit', (code, signal) => { + if (child === proc && (code !== 0 || signal)) { + console.error( + `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` + ) + } + handleChildGone(proc) + }) + child = proc + return proc +} + +function handleChildMessage(message: WatcherToHostMessage): void { + if (!message || typeof message !== 'object') { + return + } + if (message.op === 'unsubscribed') { + const resolve = pendingUnsubscribes.get(message.id) + pendingUnsubscribes.delete(message.id) + resolve?.() + return + } + const record = records.get(message.id) + if (!record) { + return + } + if (message.op === 'subscribed') { + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.resolve() + } else { + // Reached after a crash-respawn resubscribe: events emitted while the + // watcher process was down are lost, so let the caller refresh. + record.onInterruption?.() + } + return + } + if (message.op === 'subscribe-failed') { + records.delete(message.id) + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.reject(new Error(message.message)) + } else { + record.callback(new Error(message.message), []) + } + return + } + if (message.op === 'events') { + record.callback(null, message.events) + return + } + if (message.op === 'watch-error') { + record.callback(new Error(message.message), []) + } +} + +function failAllSubscriptions(err: Error): void { + for (const record of records.values()) { + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.reject(err) + } else { + record.callback(err, []) + } + } + records.clear() +} + +function handleChildGone(proc: ChildProcess): void { + if (child !== proc) { + return + } + child = null + // Why: process death released every native handle, so a pending unsubscribe + // is complete by definition — resolve rather than hang worktree deletion. + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + if (shutdownRequested || records.size === 0) { + return + } + crashTimes.push(Date.now()) + const replacement = ensureWatcherProcess() + if (!replacement) { + console.error( + '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching' + ) + failAllSubscriptions(new Error('file watcher process crashed repeatedly')) + return + } + console.error( + `[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)` + ) + for (const record of records.values()) { + sendToChild(replacement, { + op: 'subscribe', + id: record.id, + dir: record.dir, + opts: record.opts + }) + } +} + +function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { + return { + unsubscribe: (): Promise => { + if (!records.delete(record.id)) { + return Promise.resolve() + } + const proc = child + if (!proc?.connected) { + return Promise.resolve() + } + return new Promise((resolve) => { + pendingUnsubscribes.set(record.id, resolve) + sendToChild(proc, { op: 'unsubscribe', id: record.id }) + }) + } + } +} + +/** Subscribe to a directory tree via the isolated watcher process. Falls back + * to an in-process @parcel/watcher subscription when the forked entry is not + * available (tests, unbuilt trees). `onInterruption` fires after the watcher + * process crashed and this subscription was transparently re-established — + * events in the gap were lost, so callers should refresh. */ +export function subscribeViaWatcherProcess( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + onInterruption?: () => void +): Promise { + if (shouldRunInProcess(getWatcherProcessEntryPath())) { + return subscribeInProcess(dir, callback, opts) + } + const record: SubscriptionRecord = { + id: nextSubscriptionId++, + dir, + opts, + callback, + onInterruption + } + return new Promise((resolve, reject) => { + const proc = ensureWatcherProcess() + if (!proc) { + reject(new Error('file watcher process unavailable')) + return + } + records.set(record.id, record) + record.pendingSubscribe = { + resolve: () => resolve(makeSubscription(record)), + reject + } + sendToChild(proc, { op: 'subscribe', id: record.id, dir, opts }) + }) +} + +/** Kill the watcher process at app shutdown. Process death releases native + * handles without running watcher.node's crash-prone async teardown. */ +export function disposeWatcherProcess(): void { + shutdownRequested = true + const proc = child + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + records.clear() + proc?.kill() +} + +export function resetWatcherProcessForTest(): void { + disposeWatcherProcess() + shutdownRequested = false + crashTimes = [] + loggedInProcessFallback = false + nextSubscriptionId = 1 +} diff --git a/tools/repro-watcher-crash-7547/.gitignore b/tools/repro-watcher-crash-7547/.gitignore new file mode 100644 index 00000000000..fd936c82141 --- /dev/null +++ b/tools/repro-watcher-crash-7547/.gitignore @@ -0,0 +1 @@ +parcel-watcher-process.bundle.cjs diff --git a/tools/repro-watcher-crash-7547/README.md b/tools/repro-watcher-crash-7547/README.md new file mode 100644 index 00000000000..8f8261995c2 --- /dev/null +++ b/tools/repro-watcher-crash-7547/README.md @@ -0,0 +1,50 @@ +# Repro harness: watcher.node main-process crash (issue #7547) + +Manual, Windows-focused harness for the `@parcel/watcher` native crash class +(`0xc0000409` fail-fast / `0xc0000005` AV in `watcher.node`; same family as +macOS #5377 and Linux #6635). Not run in CI — it deliberately crashes +processes and hammers the filesystem. + +All watched/churned directories live under `os.tmpdir()`, never the repo. + +## Reproduce the bug (in-process watcher, pre-fix architecture) + +``` +node tools/repro-watcher-crash-7547/run.cjs delete-root 3 15000 +``` + +Spawns `child.cjs`, which uses `@parcel/watcher` **in-process** the way +`filesystem-watcher.ts` did before the fix. Scenarios (arg 1): + +- `delete-root` — subscribe to worktree-like dirs, churn files, delete the + watched root mid-churn, unsubscribe (worktree deletion during agent writes). + Crashes in seconds on Windows. +- `unsub-churn` — rapid subscribe/unsubscribe cycles against live churn. + Also crashes: the trigger is `unsubscribe()` racing active event delivery + (`CancelIo` from the wrong thread leaves a pending `ReadDirectoryChangesW` + completion pointing at a freed `Subscription`). +- `worker-mix` — worker_threads sharing the process-global native `Watcher`. +- `del-nounsub` / `del-nochurn` / `del-1lane` — minimisation variants + (deletion without unsubscribe does NOT crash; unsubscribe is the trigger). +- `overflow`, `mixed`. + +A native crash surfaces as the child's exit code (`0xC0000409` = 3221226505, +`0xC0000005` = 3221225477) and the runner stops. + +## Verify the fix (out-of-process watcher) + +Build first so the forked entry exists, then run from the repo root: + +``` +npx electron-vite build +node tools/repro-watcher-crash-7547/fixed-child.cjs 15000 +``` + +`fixed-child.cjs` esbuild-bundles the real `src/main/ipc/parcel-watcher-process.ts` +client and runs the same `delete-root` + `unsub-churn` load through the forked +`out/main/parcel-watcher-process-entry.js`. Expected output: contained watcher +process crashes (counted, recovered by respawn+resubscribe, surfaced through +`onInterruption`), a final health check that sees live events, and exit 0. + +Exit codes: 0 pass · 5 watcher not functional at end · 6 in-process fallback +used (isolation not exercised) · 7 watchdog · 8 premature exit. diff --git a/tools/repro-watcher-crash-7547/child.cjs b/tools/repro-watcher-crash-7547/child.cjs new file mode 100644 index 00000000000..d5a522c0b40 --- /dev/null +++ b/tools/repro-watcher-crash-7547/child.cjs @@ -0,0 +1,470 @@ +// Throwaway repro harness for issue #7547 — Windows main-process crash in +// @parcel/watcher (watcher.node, 0xc0000409 fail-fast). +// +// Runs ONE scenario in THIS process (the parent runner spawns us in a loop and +// watches our exit code). Each scenario mimics a real Orca usage pattern: +// +// delete-root — subscribe to worktree-like dirs, churn files, delete the +// watched root mid-churn (worktree deletion during agent +// writes). Exercises the WindowsBackend error path +// (handleWatcherError -> Watcher::notifyError on the backend +// thread). +// unsub-churn — rapid subscribe/unsubscribe cycles on dirs with live churn +// (worktree switching + 30s-grace teardown compressed). +// Exercises pending-ReadDirectoryChangesW teardown. +// worker-mix — worker_threads subscribe to the SAME dirs as the main env +// (desktop explorer watch + runtime file-watcher worker on one +// worktree). Workers exit cleanly, exit without unsubscribing, +// or are terminate()d — then new workers subscribe again so +// std::thread::id values recycle against the process-global +// shared native Watcher. +// overflow — one watched dir, massive delete/rename bursts with long +// file names to force ERROR_NOTIFY_ENUM_DIR ("Buffer +// overflow") on the backend thread. +// mixed — all of the above concurrently. +// +// Exit code 0 = survived the scenario. A native crash surfaces as the child's +// exit code (0xC0000409 = 3221226505) in the parent. +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { Worker, isMainThread, workerData } = require('node:worker_threads') + +const REPO_ROOT = path.resolve(__dirname, '..', '..') +// The exact bundled native module (@parcel/watcher 2.5.6 + watcher-win32-x64). +const watcherPath = path.join(REPO_ROOT, 'node_modules', '@parcel', 'watcher') + +// Mirrors buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) on win32 — +// identical ignore set => identical native Watcher key => cross-env sharing. +const IGNORE_DIRS = [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + 'target', + '.venv', + '__pycache__' +] +const IGNORE = IGNORE_DIRS.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) +const OPTS = { ignore: IGNORE, backend: 'windows' } + +const stats = { + eventBatches: 0, + events: 0, + errors: 0, + errorMessages: new Map(), + subscribes: 0, + unsubscribes: 0, + workersSpawned: 0, + workersTerminated: 0 +} + +function log(msg) { + process.stderr.write(`[child ${process.pid}] ${msg}\n`) +} + +function recordError(err) { + stats.errors++ + const msg = String((err && err.message) || err) + stats.errorMessages.set(msg, (stats.errorMessages.get(msg) || 0) + 1) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const rand = (n) => Math.floor(Math.random() * n) + +function makeTree(dir, files, subdirs, nameLen) { + fs.mkdirSync(dir, { recursive: true }) + const dirs = [dir] + for (let d = 0; d < subdirs; d++) { + const sub = path.join(dir, `sub-${d}`) + fs.mkdirSync(sub, { recursive: true }) + dirs.push(sub) + } + let made = 0 + for (const d of dirs) { + for (let f = 0; f < Math.ceil(files / dirs.length); f++) { + const pad = 'x'.repeat(Math.max(0, nameLen - 12)) + fs.writeFileSync(path.join(d, `f${f}-${pad}.txt`), 'seed') + made++ + } + } + return made +} + +function rmrf(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }) + } catch { + // Watched dirs can be locked mid-delete on Windows; partial deletes still + // generate the event storm we want. + } +} + +// ── Churn worker (same file, worker_threads entry) ─────────────────── + +if (!isMainThread && workerData && workerData.role === 'churn') { + // Tight synchronous churn: create/write/rename/delete inside dir until told + // to stop (dir disappearing is fine — keep going, that's the scenario). + const { dir, durationMs } = workerData + const end = Date.now() + durationMs + let i = 0 + while (Date.now() < end) { + const a = path.join(dir, `churn-${i % 200}.txt`) + const b = path.join(dir, `churn-${i % 200}.renamed.txt`) + try { + fs.writeFileSync(a, `payload-${i}`) + fs.renameSync(a, b) + fs.rmSync(b, { force: true }) + } catch { + // Root may be mid-delete; recreate it occasionally to keep churn alive. + if (i % 500 === 0) { + try { + fs.mkdirSync(dir, { recursive: true }) + } catch {} + } + } + i++ + } + process.exit(0) +} + +if (!isMainThread && workerData && workerData.role === 'subscriber') { + // Mimics file-watcher-worker.ts: subscribe in a worker env to the SAME dir + // the main env watches, then tear down per `mode`. + const { dir, mode, holdMs } = workerData + const watcher = require(watcherPath) + ;(async () => { + let sub + try { + sub = await watcher.subscribe(dir, () => {}, OPTS) + } catch { + process.exit(0) + } + await sleep(holdMs) + if (mode === 'clean') { + try { + await sub.unsubscribe() + } catch {} + process.exit(0) + } + if (mode === 'dirty-exit') { + // Exit the worker env WITHOUT unsubscribing — leaves the callback ref + // registered in the process-global shared native Watcher. + process.exit(0) + } + // mode === 'wait-terminate': linger; the main thread terminate()s us, + // possibly while the subscription (or its unsubscribe) is in flight. + await sleep(60_000) + })() +} + +// ── Scenarios (main thread) ────────────────────────────────────────── + +async function scenarioDeleteRoot(watcher, baseDir, durationMs, lane) { + const end = Date.now() + durationMs + let round = 0 + while (Date.now() < end) { + const dir = path.join(baseDir, `wt-${lane}-${round}`) + makeTree(dir, 120, 4, 20) + let sub = null + let errored = false + try { + stats.subscribes++ + sub = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + recordError(err) + errored = true + // Orca's createWatcher error path: unsubscribe from inside the + // error callback. + if (sub) { + stats.unsubscribes++ + sub.unsubscribe().catch(() => {}) + } + return + } + stats.eventBatches++ + stats.events += events.length + }, + OPTS + ) + } catch (err) { + recordError(err) + rmrf(dir) + round++ + continue + } + // Churn briefly, then delete the watched root while events are flowing. + const churn = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs: 300 } + }) + await sleep(50 + rand(100)) + rmrf(dir) + await sleep(rand(60)) + if (!errored && sub) { + stats.unsubscribes++ + await sub.unsubscribe().catch(() => {}) + } + await new Promise((r) => churn.once('exit', r)) + rmrf(dir) + round++ + } +} + +async function scenarioUnsubChurn(watcher, baseDir, durationMs, lane) { + const dir = path.join(baseDir, `unsub-${lane}`) + makeTree(dir, 150, 3, 20) + const churn = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs } + }) + const end = Date.now() + durationMs + while (Date.now() < end) { + let sub + try { + stats.subscribes++ + sub = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + recordError(err) + return + } + stats.eventBatches++ + stats.events += events.length + }, + OPTS + ) + } catch (err) { + recordError(err) + continue + } + await sleep(rand(50)) + stats.unsubscribes++ + // Half the time don't await — overlapping unsubscribe with the next + // subscribe on the same dir, like racing grace-teardown vs re-watch. + if (rand(2) === 0) { + await sub.unsubscribe().catch(() => {}) + } else { + sub.unsubscribe().catch(() => {}) + } + } + await new Promise((r) => churn.once('exit', r)) + rmrf(dir) +} + +async function scenarioWorkerMix(watcher, baseDir, durationMs, lane) { + const dir = path.join(baseDir, `wmix-${lane}`) + makeTree(dir, 100, 3, 20) + // Main env holds a long-lived subscription (desktop explorer watch). + let mainSub = null + try { + stats.subscribes++ + mainSub = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + recordError(err) + return + } + stats.eventBatches++ + stats.events += events.length + }, + OPTS + ) + } catch (err) { + recordError(err) + } + const churn = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs } + }) + const end = Date.now() + durationMs + while (Date.now() < end) { + const mode = ['clean', 'dirty-exit', 'wait-terminate'][rand(3)] + const worker = new Worker(__filename, { + workerData: { role: 'subscriber', dir, mode, holdMs: rand(150) } + }) + stats.workersSpawned++ + const exited = new Promise((r) => worker.once('exit', r)) + worker.once('error', () => {}) + if (mode === 'wait-terminate') { + await sleep(rand(100)) + stats.workersTerminated++ + await worker.terminate().catch(() => {}) + } + await Promise.race([exited, sleep(1000)]) + } + await new Promise((r) => churn.once('exit', r)) + if (mainSub) { + stats.unsubscribes++ + await mainSub.unsubscribe().catch(() => {}) + } + rmrf(dir) +} + +async function scenarioOverflow(watcher, baseDir, durationMs, lane) { + const end = Date.now() + durationMs + let round = 0 + while (Date.now() < end) { + const dir = path.join(baseDir, `ovf-${lane}-${round}`) + // Long names fill the 1MB ReadDirectoryChangesW buffer faster. + makeTree(dir, 4000, 8, 180) + let sub = null + try { + stats.subscribes++ + sub = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + recordError(err) + if (sub) { + stats.unsubscribes++ + sub.unsubscribe().catch(() => {}) + } + return + } + stats.eventBatches++ + stats.events += events.length + }, + OPTS + ) + } catch (err) { + recordError(err) + rmrf(dir) + round++ + continue + } + // Parallel delete storm: multiple churn workers + recursive delete produce + // a dense event burst while the backend thread stats each event. + const churners = Array.from( + { length: 3 }, + () => new Worker(__filename, { workerData: { role: 'churn', dir, durationMs: 500 } }) + ) + rmrf(dir) + await Promise.all(churners.map((w) => new Promise((r) => w.once('exit', r)))) + if (sub) { + stats.unsubscribes++ + await sub.unsubscribe().catch(() => {}) + } + rmrf(dir) + round++ + } +} + +// Minimisation variants of delete-root, to isolate which ingredient crashes: +// del-nounsub — subscribe, churn, delete root; NEVER call unsubscribe. +// del-nochurn — subscribe, delete root; no churn worker at all. +// del-1lane — the full delete-root pattern but a single sequential lane. +async function scenarioDeleteMinimal(watcher, baseDir, durationMs, lane, { churn, unsub }) { + const end = Date.now() + durationMs + let round = 0 + while (Date.now() < end) { + const dir = path.join(baseDir, `min-${lane}-${round}`) + makeTree(dir, 120, 4, 20) + let sub = null + try { + stats.subscribes++ + sub = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + recordError(err) + return + } + stats.eventBatches++ + stats.events += events.length + }, + OPTS + ) + } catch (err) { + recordError(err) + rmrf(dir) + round++ + continue + } + let churnWorker = null + if (churn) { + churnWorker = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs: 300 } + }) + await sleep(50 + rand(100)) + } + rmrf(dir) + await sleep(100) + if (unsub && sub) { + stats.unsubscribes++ + await sub.unsubscribe().catch(() => {}) + } + if (churnWorker) { + await new Promise((r) => churnWorker.once('exit', r)) + } + rmrf(dir) + round++ + } +} + +const SCENARIOS = { + 'delete-root': { fn: scenarioDeleteRoot, lanes: 6 }, + 'del-nounsub': { + fn: (w, b, d, l) => scenarioDeleteMinimal(w, b, d, l, { churn: true, unsub: false }), + lanes: 6 + }, + 'del-nochurn': { + fn: (w, b, d, l) => scenarioDeleteMinimal(w, b, d, l, { churn: false, unsub: false }), + lanes: 6 + }, + 'del-1lane': { + fn: (w, b, d, l) => scenarioDeleteMinimal(w, b, d, l, { churn: true, unsub: true }), + lanes: 1 + }, + 'unsub-churn': { fn: scenarioUnsubChurn, lanes: 6 }, + 'worker-mix': { fn: scenarioWorkerMix, lanes: 4 }, + overflow: { fn: scenarioOverflow, lanes: 2 } +} + +async function main() { + const scenarioName = process.argv[2] || 'mixed' + const durationMs = Number(process.argv[3] || 15000) + // Outside the repo worktree: a live Orca instance may be watching the repo, + // and this churn must not feed the production watcher. + const baseDir = path.join(os.tmpdir(), 'orca-7547-harness', `run-${process.pid}`) + fs.mkdirSync(baseDir, { recursive: true }) + + const watcher = require(watcherPath) + const jobs = [] + const names = scenarioName === 'mixed' ? Object.keys(SCENARIOS) : [scenarioName] + for (const name of names) { + const s = SCENARIOS[name] + if (!s) { + log(`unknown scenario: ${name}`) + process.exit(2) + } + const lanes = scenarioName === 'mixed' ? Math.max(1, Math.floor(s.lanes / 2)) : s.lanes + for (let lane = 0; lane < lanes; lane++) { + jobs.push(s.fn(watcher, baseDir, durationMs, lane)) + } + } + await Promise.all(jobs) + + const errs = Array.from(stats.errorMessages.entries()) + .map(([m, n]) => `${n}x "${m}"`) + .join(', ') + log( + `done: subs=${stats.subscribes} unsubs=${stats.unsubscribes} batches=${stats.eventBatches} ` + + `events=${stats.events} errors=${stats.errors} workers=${stats.workersSpawned} ` + + `terminated=${stats.workersTerminated}${errs ? ` errMsgs: ${errs}` : ''}` + ) + rmrf(baseDir) + process.exit(0) +} + +if (isMainThread) { + main().catch((err) => { + log(`harness error: ${err && err.stack}`) + process.exit(3) + }) +} diff --git a/tools/repro-watcher-crash-7547/fixed-child.cjs b/tools/repro-watcher-crash-7547/fixed-child.cjs new file mode 100644 index 00000000000..f45d65bc7a9 --- /dev/null +++ b/tools/repro-watcher-crash-7547/fixed-child.cjs @@ -0,0 +1,334 @@ +// Fixed-architecture harness for issue #7547: runs the SAME scenarios that +// crash the in-process watcher (see child.cjs), but through the real +// parcel-watcher-process client (esbuild-bundled from src/main/ipc) which +// forks the real out/main/parcel-watcher-process-entry.js. +// +// Success = this process exits 0 (the app-process stand-in survived), the +// watcher stayed functional (final health check receives a live event), and +// no in-process fallback was used. Contained watcher-child crashes are +// EXPECTED and counted — they prove the native bug still fires but no longer +// reaches the host. +// +// Must be run with cwd = repo root (entry path resolves against cwd). +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { Worker, isMainThread, workerData } = require('node:worker_threads') + +const REPO_ROOT = path.resolve(__dirname, '..', '..') + +// The real host client, bundled on demand so the harness always exercises the +// current src/main/ipc/parcel-watcher-process.ts. +const bundlePath = path.join(__dirname, 'parcel-watcher-process.bundle.cjs') +if (isMainThread) { + const clientSrc = path.join(REPO_ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process.ts') + if ( + !fs.existsSync(bundlePath) || + fs.statSync(bundlePath).mtimeMs < fs.statSync(clientSrc).mtimeMs + ) { + require('node:child_process').execSync( + `npx esbuild ${JSON.stringify(clientSrc)} --bundle --platform=node --format=cjs ` + + `--external:electron --external:@parcel/watcher --outfile=${JSON.stringify(bundlePath)}`, + { cwd: REPO_ROOT, stdio: 'inherit' } + ) + } + const entry = path.join(process.cwd(), 'out', 'main', 'parcel-watcher-process-entry.js') + if (!fs.existsSync(entry)) { + process.stderr.write( + `[fixed-harness] missing ${entry}\nRun from the repo root after building (npx electron-vite build).\n` + ) + process.exit(2) + } +} +const client = require(bundlePath) + +const IGNORE_DIRS = [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + 'target', + '.venv', + '__pycache__' +] +const OPTS = { + ignore: IGNORE_DIRS.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]), + backend: 'windows' +} + +const stats = { + subscribes: 0, + unsubscribes: 0, + events: 0, + watchErrors: 0, + interruptions: 0, + containedCrashes: 0, + fallbackDetected: false +} + +// The client reports child crashes / fallback via console.error|warn — hook +// them to count contained crashes and detect a broken (in-process) setup. +const realError = console.error.bind(console) +console.error = (...args) => { + const line = args.map(String).join(' ') + if (line.includes('[parcel-watcher-process] watcher process exited')) { + stats.containedCrashes++ + } + realError(...args) +} +const realWarn = console.warn.bind(console) +console.warn = (...args) => { + const line = args.map(String).join(' ') + if (line.includes('using in-process watcher')) { + stats.fallbackDetected = true + } + realWarn(...args) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const rand = (n) => Math.floor(Math.random() * n) + +function makeTree(dir, files, subdirs) { + fs.mkdirSync(dir, { recursive: true }) + const dirs = [dir] + for (let d = 0; d < subdirs; d++) { + const sub = path.join(dir, `sub-${d}`) + fs.mkdirSync(sub, { recursive: true }) + dirs.push(sub) + } + for (const d of dirs) { + for (let f = 0; f < Math.ceil(files / dirs.length); f++) { + fs.writeFileSync(path.join(d, `f${f}.txt`), 'seed') + } + } +} + +function rmrf(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }) + } catch {} +} + +if (!isMainThread && workerData && workerData.role === 'churn') { + const { dir, durationMs } = workerData + const end = Date.now() + durationMs + let i = 0 + while (Date.now() < end) { + const a = path.join(dir, `churn-${i % 200}.txt`) + const b = path.join(dir, `churn-${i % 200}.renamed.txt`) + try { + fs.writeFileSync(a, `payload-${i}`) + fs.renameSync(a, b) + fs.rmSync(b, { force: true }) + } catch { + if (i % 500 === 0) { + try { + fs.mkdirSync(dir, { recursive: true }) + } catch {} + } + } + i++ + } + process.exit(0) +} + +function subscribeLikeOrca(dir) { + let subRef = { current: null } + stats.subscribes++ + return client + .subscribeViaWatcherProcess( + dir, + (err, events) => { + if (err) { + stats.watchErrors++ + // Orca's error path: unsubscribe from inside the error callback. + if (subRef.current) { + stats.unsubscribes++ + subRef.current.unsubscribe().catch(() => {}) + subRef.current = null + } + return + } + stats.events += events.length + }, + OPTS, + () => { + stats.interruptions++ + } + ) + .then((sub) => { + subRef.current = sub + return subRef + }) +} + +async function deleteRootLane(baseDir, durationMs, lane) { + const end = Date.now() + durationMs + let round = 0 + while (Date.now() < end) { + const dir = path.join(baseDir, `wt-${lane}-${round}`) + makeTree(dir, 120, 4) + let subRef + try { + subRef = await subscribeLikeOrca(dir) + } catch { + rmrf(dir) + round++ + continue + } + const churn = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs: 300 } + }) + // Attach at spawn: a listener added after the worker already exited never + // fires, which hangs the lane (or lets the process exit 0 mid-run). + const churnDone = new Promise((r) => churn.once('exit', r)) + await sleep(50 + rand(100)) + rmrf(dir) + await sleep(rand(60)) + if (subRef.current) { + stats.unsubscribes++ + await subRef.current.unsubscribe().catch(() => {}) + subRef.current = null + } + await churnDone + rmrf(dir) + round++ + } +} + +async function unsubChurnLane(baseDir, durationMs, lane) { + const dir = path.join(baseDir, `unsub-${lane}`) + makeTree(dir, 150, 3) + const churn = new Worker(__filename, { + workerData: { role: 'churn', dir, durationMs } + }) + const churnDone = new Promise((r) => churn.once('exit', r)) + const end = Date.now() + durationMs + while (Date.now() < end) { + let subRef + try { + subRef = await subscribeLikeOrca(dir) + } catch { + await sleep(20) + continue + } + await sleep(rand(50)) + if (subRef.current) { + stats.unsubscribes++ + const p = subRef.current.unsubscribe().catch(() => {}) + subRef.current = null + if (rand(2) === 0) { + await p + } + } + } + await churnDone + rmrf(dir) +} + +// Health check must outlast the client's 30s crash-loop cooldown: if the +// breaker tripped during the scenarios, watching is expected to recover on a +// later subscribe, and THAT is what we verify. +async function finalHealthCheck(baseDir) { + const deadline = Date.now() + 45_000 + let attempt = 0 + while (Date.now() < deadline) { + attempt++ + const dir = path.join(baseDir, `health-${attempt}`) + makeTree(dir, 5, 0) + let gotEvent = false + let sub = null + try { + sub = await client.subscribeViaWatcherProcess(dir, (err, events) => { + if (!err && events.length > 0) { + gotEvent = true + } + }, OPTS) + } catch (err) { + process.stderr.write(`[fixed-harness] health subscribe attempt ${attempt} failed: ${err.message}\n`) + rmrf(dir) + await sleep(5000) + continue + } + const probeDeadline = Date.now() + 8000 + while (!gotEvent && Date.now() < probeDeadline) { + fs.writeFileSync(path.join(dir, 'probe.txt'), `probe-${Date.now()}`) + await sleep(200) + } + await sub.unsubscribe().catch(() => {}) + rmrf(dir) + if (gotEvent) { + return true + } + process.stderr.write(`[fixed-harness] health attempt ${attempt}: no events in 8s; retrying\n`) + await sleep(3000) + } + return false +} + +let completed = false + +async function main() { + const durationMs = Number(process.argv[2] || 15000) + const baseDir = path.join(os.tmpdir(), 'orca-7547-harness-fixed', `run-${process.pid}`) + fs.mkdirSync(baseDir, { recursive: true }) + + // Watchdog: a stuck lane must fail loudly, and a premature natural exit + // (empty event loop) must not read as success. + const watchdog = setTimeout(() => { + process.stderr.write('[fixed-harness] FAIL: watchdog — scenarios did not complete\n') + process.exit(7) + }, durationMs * 4 + 60_000) + process.on('exit', (code) => { + if (!completed && code === 0) { + process.exitCode = 8 + process.stderr.write('[fixed-harness] FAIL: premature exit before completion\n') + } + }) + + const lanes = [] + for (let lane = 0; lane < 6; lane++) { + lanes.push(deleteRootLane(baseDir, durationMs, lane)) + } + for (let lane = 0; lane < 6; lane++) { + lanes.push(unsubChurnLane(baseDir, durationMs, lane)) + } + await Promise.all(lanes) + + const healthy = await finalHealthCheck(baseDir) + client.disposeWatcherProcess() + rmrf(baseDir) + completed = true + clearTimeout(watchdog) + + // Summary via stderr + exitCode (not process.exit): stdout writes to a pipe + // are async on Windows and process.exit() drops them. + process.stderr.write( + `[fixed-harness] subs=${stats.subscribes} unsubs=${stats.unsubscribes} events=${stats.events} ` + + `watchErrors=${stats.watchErrors} containedCrashes=${stats.containedCrashes} ` + + `interruptions=${stats.interruptions} healthy=${healthy} fallback=${stats.fallbackDetected}\n` + ) + if (stats.fallbackDetected) { + process.stderr.write('[fixed-harness] FAIL: in-process fallback used — isolation not exercised\n') + process.exitCode = 6 + return + } + if (!healthy) { + process.stderr.write('[fixed-harness] FAIL: watcher not functional after scenarios\n') + process.exitCode = 5 + return + } + process.stderr.write('[fixed-harness] PASS: host survived; watcher functional\n') + process.exitCode = 0 +} + +if (isMainThread) { + main().catch((err) => { + console.error('[fixed-harness] harness error:', err) + process.exit(3) + }) +} diff --git a/tools/repro-watcher-crash-7547/run.cjs b/tools/repro-watcher-crash-7547/run.cjs new file mode 100644 index 00000000000..0d10b56a2f5 --- /dev/null +++ b/tools/repro-watcher-crash-7547/run.cjs @@ -0,0 +1,74 @@ +// Parent runner for the #7547 watcher.node crash harness. Spawns child.cjs in +// a loop and reports native crash exit codes (0xC0000409 fail-fast, 0xC0000005 +// AV, etc.). Usage: +// node run.cjs [iterations] [durationMs] +'use strict' + +const { spawn } = require('node:child_process') +const path = require('node:path') + +const CHILD = path.join(__dirname, 'child.cjs') +const KNOWN = { + 3221226505: 'STATUS_STACK_BUFFER_OVERRUN (0xC0000409) — fail-fast/abort << TARGET', + 3221225477: 'STATUS_ACCESS_VIOLATION (0xC0000005)', + 3221225725: 'STATUS_STACK_OVERFLOW (0xC00000FD)', + 3221226356: 'STATUS_HEAP_CORRUPTION (0xC0000374)', + 134: 'SIGABRT (128+6)' +} + +function hex(code) { + return code >= 0 ? `0x${code.toString(16).toUpperCase()}` : String(code) +} + +function runOnce(scenario, durationMs) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CHILD, scenario, String(durationMs)], { + stdio: ['ignore', 'inherit', 'pipe'] + }) + let stderr = '' + child.stderr.on('data', (d) => { + stderr += d + process.stderr.write(d) + }) + child.on('exit', (code, signal) => resolve({ code, signal, stderr })) + }) +} + +async function main() { + const scenario = process.argv[2] || 'mixed' + const iterations = Number(process.argv[3] || 10) + const durationMs = Number(process.argv[4] || 15000) + const scenarios = + scenario === 'all' + ? ['delete-root', 'unsub-churn', 'worker-mix', 'overflow', 'mixed'] + : [scenario] + + const results = [] + let crashed = false + outer: for (const s of scenarios) { + for (let i = 1; i <= iterations; i++) { + const started = Date.now() + console.log(`\n=== [${s}] iteration ${i}/${iterations} (${durationMs}ms) ===`) + const { code, signal } = await runOnce(s, durationMs) + const elapsed = Date.now() - started + const meaning = KNOWN[code] || (code === 0 ? 'clean exit' : 'unexpected') + console.log(`=== [${s}] iter ${i}: exit=${code} (${hex(code ?? -1)}) signal=${signal} ${meaning} after ${elapsed}ms`) + results.push({ scenario: s, iteration: i, code, signal, elapsed }) + if (code !== 0 && code !== null) { + crashed = true + console.log(`\n*** NATIVE CRASH REPRODUCED in scenario "${s}" (exit ${hex(code)}) ***`) + break outer + } + } + } + + console.log('\n──── summary ────') + for (const r of results) { + console.log( + `${r.scenario.padEnd(12)} #${r.iteration} exit=${hex(r.code ?? -1)} ${r.signal || ''} ${r.elapsed}ms` + ) + } + process.exit(crashed ? 1 : 0) +} + +main() From 8368e946dfcee93c5d04ca333214fec0bf06149f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:50:57 -0700 Subject: [PATCH 054/152] Fix orchestration agent prompt injection (#7758) Co-authored-by: Orca --- config/reliability-gates.jsonc | 138 +++++ src/main/runtime/orca-runtime.test.ts | 109 ++++ src/main/runtime/orca-runtime.ts | 83 +++ .../runtime/orchestration/coordinator.test.ts | 10 +- src/main/runtime/orchestration/coordinator.ts | 7 +- .../runtime/rpc/methods/orchestration.test.ts | 36 +- src/main/runtime/rpc/methods/orchestration.ts | 2 +- src/shared/agent-prompt-injection.test.ts | 44 ++ src/shared/agent-prompt-injection.ts | 43 ++ tools/repro-orchestration-long-prompt.mjs | 504 ++++++++++++++++++ 10 files changed, 962 insertions(+), 14 deletions(-) create mode 100644 src/shared/agent-prompt-injection.test.ts create mode 100644 src/shared/agent-prompt-injection.ts create mode 100644 tools/repro-orchestration-long-prompt.mjs diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1975f41c7f9..73162aee6f7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1463,6 +1463,144 @@ ], "demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence." }, + { + "id": "terminal-input.agent-prompt-injection", + "title": "Orchestration agent prompts arrive as bracketed paste before submit", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-contract-and-cli-repro", + "surfaces": [ + "terminal input", + "agent prompt injection", + "orchestration dispatch", + "PTY writes", + "bracketed paste" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local" + ], + "coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7226" + ], + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.", + "oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000" + ], + "testFiles": [ + "src/shared/agent-prompt-injection.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/methods/orchestration.test.ts", + "src/main/runtime/orchestration/coordinator.test.ts", + "tools/repro-orchestration-long-prompt.mjs" + ], + "assertionRefs": [ + { + "file": "src/shared/agent-prompt-injection.test.ts", + "assertions": [ + "agent prompts are always framed as bracketed paste", + "submit stays separate from the paste frame", + "embedded ESC bytes are made inert before framing", + "chunk reconstruction preserves the paste frame" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "runtime writes bracketed paste before a delayed submit", + "large prompt frames are chunked and reconstructed before submit", + "partial prompt write failure closes the paste frame and does not submit" + ] + }, + { + "file": "src/main/runtime/rpc/methods/orchestration.test.ts", + "assertions": [ + "orchestration.dispatch uses the agent prompt path for injected preambles", + "raw terminal.send is not called for injected task prompts", + "failed prompt injection rolls back the active dispatch" + ] + }, + { + "file": "src/main/runtime/orchestration/coordinator.test.ts", + "assertions": [ + "coordinator dispatch failures from prompt injection circuit-break through the DB", + "silent-skip paths do not attempt prompt injection" + ] + }, + { + "file": "tools/repro-orchestration-long-prompt.mjs", + "assertions": [ + "fake Codex-like worker observes submit after long orchestration dispatch", + "32KB task marker survives before submit", + "prompt bytes include a bracketed-paste frame with zero unframed line breaks" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "result": "passed", + "durationSeconds": 7.4, + "summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000", + "result": "passed", + "durationSeconds": 2.2, + "summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "runtime contract tests plus optional local dev-runtime CLI repro" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "First local macOS evidence only; needs repeated dev-runtime harness runs and provider matrix evidence before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI." + }, + "performanceBudget": { + "required": true, + "evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work." + }, + "promotionCriteria": [ + "Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.", + "Add daemon, SSH, remote-runtime, Linux, and Windows evidence or mark narrower provider scope.", + "Capture stable red/green intentional-break evidence in CI before blocking promotion." + ], + "knownGaps": [ + "Live harness command currently expects an already-running dev runtime and generated out/bin/orca-dev wrapper.", + "No Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime live evidence yet.", + "Push-on-idle orchestration message banners remain outside this dispatch-prompt gate." + ], + "demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner." + }, { "id": "terminal-render.windows-cjk-repaint", "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4acf193e0d3..62e9cbe7d62 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -60,6 +60,11 @@ import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR } from '../../shared/terminal-input' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_BRACKETED_PASTE_START, + buildAgentPromptPasteBytes +} from '../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { registerSshFilesystemProvider, @@ -9298,6 +9303,110 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['continue', '\r']) }) + it('sends agent prompts as bracketed paste before submit', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = 'line one\nline two\x1b[201~' + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + await vi.runAllTimersAsync() + const result = await sendPromise + + const pasted = [ + AGENT_PROMPT_BRACKETED_PASTE_START, + 'line one\nline two[201~', + AGENT_PROMPT_BRACKETED_PASTE_END + ].join('') + expect(result).toMatchObject({ + handle, + accepted: true, + bytesWritten: Buffer.byteLength(`${pasted}\r`, 'utf8') + }) + expect(writes).toEqual([pasted, '\r']) + } finally { + vi.useRealTimers() + } + }) + + it('chunks large agent prompt paste frames before delayed submit', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + await vi.runAllTimersAsync() + const result = await sendPromise + + const pasteWrites = writes.slice(0, -1) + expect(result.bytesWritten).toBe( + Buffer.byteLength(`${buildAgentPromptPasteBytes(prompt)}\r`, 'utf8') + ) + expect(writes.at(-1)).toBe('\r') + expect(pasteWrites.length).toBeGreaterThan(1) + expect(pasteWrites.join('')).toBe(buildAgentPromptPasteBytes(prompt)) + expect(pasteWrites[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START) + expect(pasteWrites.at(-1)).toContain(AGENT_PROMPT_BRACKETED_PASTE_END) + } finally { + vi.useRealTimers() + } + }) + + it('closes an incomplete agent prompt paste when a later chunk write fails', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + let writeCount = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writeCount += 1 + writes.push(data) + return writeCount !== 2 + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') + await vi.runAllTimersAsync() + + await sendRejection + expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START) + expect(writes.at(-1)).toBe(AGENT_PROMPT_BRACKETED_PASTE_END) + expect(writes).not.toContain('\r') + } finally { + vi.useRealTimers() + } + }) + it('chunks large terminal.send text before provider writes', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ba2b4d946aa..12b6d6036d9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -34,6 +34,12 @@ import { TERMINAL_INPUT_TOO_LARGE_ERROR, iterateTerminalInputChunks } from '../../shared/terminal-input' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_SUBMIT, + AGENT_PROMPT_SUBMIT_DELAY_MS, + buildAgentPromptPasteBytes +} from '../../shared/agent-prompt-injection' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { cleanupClaimedCloneTarget, @@ -8837,6 +8843,35 @@ export class OrcaRuntimeService { } } + async sendTerminalAgentPrompt( + handle: string, + prompt: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + } = {} + ): Promise { + const payload = buildAgentPromptPasteBytes(prompt) + const bytesWritten = Buffer.byteLength(`${payload}${AGENT_PROMPT_SUBMIT}`, 'utf8') + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + await this.writeTerminalAgentPrompt(pty.pty.ptyId, payload, options) + return { handle, accepted: true, bytesWritten } + } + + const { leaf } = this.getLiveLeafForHandle(handle) + if (!leaf.writable || !leaf.ptyId) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + await this.writeTerminalAgentPrompt(leaf.ptyId, payload, options) + return { handle, accepted: true, bytesWritten } + } + async getTerminalAgentStatus(handle: string): Promise { const terminal = this.getTerminalAgentStatusSnapshot(handle) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) @@ -9194,6 +9229,54 @@ export class OrcaRuntimeService { } } + private async writeTerminalAgentPrompt( + ptyId: string, + pastePayload: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + } = {} + ): Promise { + let wrotePasteBytes = false + let completedPaste = false + try { + const chunks = iterateTerminalInputChunks(pastePayload) + let chunk = chunks.next() + while (!chunk.done) { + await options.beforeWrite?.(ptyId) + const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + wrotePasteBytes = true + chunk = chunks.next() + if (!chunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + completedPaste = true + } catch (error) { + if (wrotePasteBytes && !completedPaste) { + this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + } + throw error + } + + await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS)) + try { + await options.beforeWrite?.(ptyId) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error + } + const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false + if (!suffixWrote) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } + } + async waitForTerminal( handle: string, options?: { diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index a10c4ea2091..8593fd81622 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -40,8 +40,8 @@ function createMockRuntime(): CoordinatorRuntime & { setProbeDrift(result: DriftResult): void { mock.probeDriftResult = result }, - async sendTerminal(handle: string, action: { text?: string }) { - mock.sentMessages.push({ handle, text: action.text ?? '' }) + async sendTerminalAgentPrompt(handle: string, prompt: string) { + mock.sentMessages.push({ handle, text: prompt }) return { handle, accepted: true, bytesWritten: 0 } }, async listTerminals() { @@ -275,7 +275,7 @@ describe('Coordinator', () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] - runtime.sendTerminal = async () => { + runtime.sendTerminalAgentPrompt = async () => { throw new Error('terminal_not_writable') } @@ -690,8 +690,8 @@ describe('Coordinator', () => { const result = await runPromise // Why: silent-skip must NOT burn the circuit-breaker budget. Task must - // stay in `ready`; failDispatch must NOT be called; sendTerminal must - // NOT be called; no dispatch context should exist. + // stay in `ready`; failDispatch must NOT be called; no prompt injection + // should happen; no dispatch context should exist. expect(runtime.sentMessages).toHaveLength(0) expect(db.getTask(task.id)?.status).toBe('ready') expect(db.getDispatchContext(task.id)).toBeUndefined() diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 897290c329e..2313dfdf320 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -5,7 +5,7 @@ import { buildDispatchPreamble } from './preamble' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' export type CoordinatorRuntime = { - sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise + sendTerminalAgentPrompt(handle: string, prompt: string): Promise listTerminals( worktreeSelector?: string, limit?: number @@ -495,10 +495,7 @@ export class Coordinator { } try { - await this.runtime.sendTerminal(targetHandle, { - text: preamble + gateContext, - enter: true - }) + await this.runtime.sendTerminalAgentPrompt(targetHandle, preamble + gateContext) } catch (err) { const updated = this.db.failDispatch( dispatch.id, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 8fd0da2bfa6..e5255595a6a 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1067,7 +1067,9 @@ describe('orchestration RPC methods', () => { setup() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) - vi.spyOn(runtime, 'sendTerminal').mockRejectedValue(new Error('terminal_not_writable')) + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockRejectedValue( + new Error('terminal_not_writable') + ) await expect( call('orchestration.dispatch', { @@ -1085,7 +1087,7 @@ describe('orchestration RPC methods', () => { setup() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) - const send = vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({ + const send = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ handle: 'term_a', accepted: true, bytesWritten: 1 @@ -1098,7 +1100,35 @@ describe('orchestration RPC methods', () => { devMode: true }) - expect(send.mock.calls[0]?.[1].text).toContain('orca-dev orchestration send') + expect(send).toHaveBeenCalledWith( + 'term_a', + expect.stringContaining('orca-dev orchestration send') + ) + }) + + it('injects preamble through the agent prompt path instead of raw terminal send', async () => { + setup() + const task = db.createTask({ spec: 'line one\nline two' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + const agentPrompt = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_a', + accepted: true, + bytesWritten: 1 + }) + const rawSend = vi.spyOn(runtime, 'sendTerminal') + + await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + from: 'term_coord' + }) + + expect(agentPrompt).toHaveBeenCalledWith( + 'term_a', + expect.stringContaining('line one\nline two') + ) + expect(rawSend).not.toHaveBeenCalled() }) it('rejects inject to terminal without recognized agent', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 705791047cf..f22ad62d8f4 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -492,7 +492,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ let injected = false if (params.inject) { try { - await runtime.sendTerminal(to, { text: preamble, enter: true }) + await runtime.sendTerminalAgentPrompt(to, preamble) injected = true } catch (err) { db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) diff --git a/src/shared/agent-prompt-injection.test.ts b/src/shared/agent-prompt-injection.test.ts new file mode 100644 index 00000000000..5ad3e42d4d1 --- /dev/null +++ b/src/shared/agent-prompt-injection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_BRACKETED_PASTE_START, + buildAgentPromptPasteBytes, + buildAgentPromptSubmitBytes, + iterateAgentPromptPasteChunks, + sanitizeAgentPromptText +} from './agent-prompt-injection' + +const BEGIN = AGENT_PROMPT_BRACKETED_PASTE_START +const END = AGENT_PROMPT_BRACKETED_PASTE_END + +describe('agent prompt injection bytes', () => { + it('always bracket-pastes prompts so agent TUIs treat newlines as content', () => { + expect(buildAgentPromptPasteBytes('line one\nline two')).toBe( + `${BEGIN}line one\nline two${END}` + ) + }) + + it('keeps submit separate from the paste frame', () => { + expect(buildAgentPromptPasteBytes('hello')).not.toContain('\r') + expect(buildAgentPromptSubmitBytes()).toBe('\r') + }) + + it('sanitizes embedded escape bytes before framing', () => { + const bytes = buildAgentPromptPasteBytes('before\x1b[201~after\x1b') + expect(bytes).toBe(`${BEGIN}before[201~after${END}`) + expect(bytes.slice(BEGIN.length, -END.length)).not.toContain('\x1b') + }) + + it('exposes the sanitizer for tests and diagnostics', () => { + expect(sanitizeAgentPromptText('a\x1bb')).toBe('ab') + }) + + it('chunks without changing the reconstructed paste frame', () => { + const prompt = `header\n${'abc123'.repeat(200)}` + const chunks = [...iterateAgentPromptPasteChunks(prompt, 31)] + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('')).toBe(buildAgentPromptPasteBytes(prompt)) + expect(chunks.join('')).toContain(`${BEGIN}header\n`) + expect(chunks.join('')).toContain(END) + }) +}) diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts new file mode 100644 index 00000000000..1d57fb0607c --- /dev/null +++ b/src/shared/agent-prompt-injection.ts @@ -0,0 +1,43 @@ +import { iterateTerminalInputChunks, TERMINAL_INPUT_CHUNK_MAX_BYTES } from './terminal-input' + +export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~' +export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~' +export const AGENT_PROMPT_SUBMIT = '\r' + +// Why: Codex/Claude can need a render turn after bracketed-paste end before +// Enter is accepted as submit, not paste content. Match the proven runtime gap. +export const AGENT_PROMPT_SUBMIT_DELAY_MS = 500 + +const ESCAPE = '\x1b' +const INERT_ESCAPE = '' + +export function sanitizeAgentPromptText(text: string): string { + let escapeIndex = text.indexOf(ESCAPE) + if (escapeIndex === -1) { + return text + } + + let sanitized = '' + let start = 0 + while (escapeIndex !== -1) { + sanitized += `${text.slice(start, escapeIndex)}${INERT_ESCAPE}` + start = escapeIndex + ESCAPE.length + escapeIndex = text.indexOf(ESCAPE, start) + } + return sanitized + text.slice(start) +} + +export function buildAgentPromptPasteBytes(prompt: string): string { + return `${AGENT_PROMPT_BRACKETED_PASTE_START}${sanitizeAgentPromptText(prompt)}${AGENT_PROMPT_BRACKETED_PASTE_END}` +} + +export function buildAgentPromptSubmitBytes(): string { + return AGENT_PROMPT_SUBMIT +} + +export function* iterateAgentPromptPasteChunks( + prompt: string, + maxChunkBytes = TERMINAL_INPUT_CHUNK_MAX_BYTES +): Generator { + yield* iterateTerminalInputChunks(buildAgentPromptPasteBytes(prompt), maxChunkBytes) +} diff --git a/tools/repro-orchestration-long-prompt.mjs b/tools/repro-orchestration-long-prompt.mjs new file mode 100644 index 00000000000..6951e29d1fb --- /dev/null +++ b/tools/repro-orchestration-long-prompt.mjs @@ -0,0 +1,504 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const SCRIPT_PATH = import.meta.filename +const BEGIN = '\x1b[200~' +const END = '\x1b[201~' + +const DEFAULT_SIZE_KB = 80 +const DEFAULT_TIMEOUT_MS = 20_000 +const DEFAULT_MODE = 'codex-like' + +function argValue(name, fallback = undefined) { + const prefix = `--${name}=` + const inline = process.argv.find((arg) => arg.startsWith(prefix)) + if (inline) { + return inline.slice(prefix.length) + } + const index = process.argv.indexOf(`--${name}`) + if (index !== -1) { + return process.argv[index + 1] ?? fallback + } + return fallback +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`) +} + +function parsePositiveInteger(name, fallback) { + const raw = argValue(name) + if (raw === undefined) { + return fallback + } + const value = Number(raw) + if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + throw new Error(`--${name} must be a positive integer`) + } + return value +} + +function shellQuote(value) { + if (process.platform === 'win32') { + return `"${String(value).replace(/"/g, '\\"')}"` + } + return `'${String(value).replace(/'/g, "'\\''")}'` +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }) + return + } + const error = new Error(`${command} ${args.join(' ')} exited ${code}`) + error.stdout = stdout + error.stderr = stderr + error.code = code + reject(error) + }) + }) +} + +function parseCliJson(output) { + const text = output.trim() + if (!text) { + throw new Error('CLI returned empty JSON output') + } + return JSON.parse(text) +} + +async function callOrca(cli, args, options = {}) { + const result = await runCommand(cli, [...args, '--json'], options) + const parsed = parseCliJson(result.stdout) + if (parsed.ok === false) { + throw new Error(parsed.error?.message ?? JSON.stringify(parsed.error)) + } + return parsed.result ?? parsed +} + +function buildLongSpec(sizeKb, marker) { + const targetBytes = sizeKb * 1024 + const header = [ + `ORCA_LONG_PROMPT_REPRO_START ${marker}`, + 'This task is intentionally long so orchestration dispatch crosses terminal input chunks.', + 'The receiver expects the end marker to arrive before the submit byte.' + ].join('\n') + const lines = [header] + let index = 0 + while ( + Buffer.byteLength(`${lines.join('\n')}\nORCA_LONG_PROMPT_REPRO_END ${marker}`, 'utf8') < + targetBytes + ) { + lines.push( + `line ${String(index).padStart(5, '0')} ${marker} ${'abcdefghijklmnopqrstuvwxyz 0123456789 '.repeat(3)}` + ) + index += 1 + } + lines.push(`ORCA_LONG_PROMPT_REPRO_END ${marker}`) + return lines.join('\n') +} + +function readNested(result, pathParts) { + let current = result + for (const part of pathParts) { + current = current?.[part] + } + return current +} + +async function waitForReport(reportPath, timeoutMs) { + const startedAt = Date.now() + let lastError = null + while (Date.now() - startedAt < timeoutMs) { + try { + return JSON.parse(await readFile(reportPath, 'utf8')) + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + const error = new Error(`Timed out waiting for report: ${reportPath}`) + error.cause = lastError + throw error +} + +async function tryCloseTerminal(cli, handle, cwd) { + if (!handle) { + return + } + try { + await callOrca(cli, ['terminal', 'close', '--terminal', handle], { cwd }) + } catch { + // Best-effort cleanup; the report is more useful than a close failure. + } +} + +async function parentMain() { + const cli = argValue('cli', process.env.ORCA_REPRO_CLI ?? 'orca') + const mode = argValue('mode', DEFAULT_MODE) + if (!new Set(['wire', 'codex-like']).has(mode)) { + throw new Error('--mode must be wire or codex-like') + } + const sizeKb = parsePositiveInteger('size-kb', DEFAULT_SIZE_KB) + const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) + const cwd = path.resolve(argValue('worktree', process.cwd())) + const keepTerminals = hasFlag('keep-terminals') + const discardReport = hasFlag('discard-report') + const marker = `marker_${randomUUID().replace(/-/g, '')}` + const spec = buildLongSpec(sizeKb, marker) + const tempDir = path.join(tmpdir(), `orca-orchestration-long-prompt-${process.pid}-${Date.now()}`) + await mkdir(tempDir, { recursive: true }) + const workerReportPath = path.join(tempDir, 'worker-report.json') + + console.log(`runtime: ${cli} status`) + await callOrca(cli, ['status'], { cwd }) + + const coordinator = await callOrca( + cli, + [ + 'terminal', + 'create', + '--worktree', + `path:${cwd}`, + '--title', + 'orchestration repro coordinator', + '--command', + `${shellQuote(process.execPath)} ${shellQuote(SCRIPT_PATH)} --fake-coordinator` + ], + { cwd } + ) + const coordinatorHandle = readNested(coordinator, ['terminal', 'handle']) + if (!coordinatorHandle) { + throw new Error('Could not create coordinator terminal') + } + + const workerCommand = [ + shellQuote(process.execPath), + shellQuote(SCRIPT_PATH), + '--fake-worker', + '--cli', + shellQuote(cli), + '--mode', + shellQuote(mode), + '--report', + shellQuote(workerReportPath), + '--marker', + shellQuote(marker), + '--timeout-ms', + String(timeoutMs) + ].join(' ') + const worker = await callOrca( + cli, + [ + 'terminal', + 'create', + '--worktree', + `path:${cwd}`, + '--title', + 'orchestration repro fake codex', + '--command', + workerCommand + ], + { cwd } + ) + const workerHandle = readNested(worker, ['terminal', 'handle']) + if (!workerHandle) { + throw new Error('Could not create worker terminal') + } + + let taskId = null + try { + await callOrca( + cli, + [ + 'terminal', + 'wait', + '--terminal', + workerHandle, + '--for', + 'tui-idle', + '--timeout-ms', + '10000' + ], + { cwd } + ) + const task = await callOrca( + cli, + [ + 'orchestration', + 'task-create', + '--spec', + spec, + '--task-title', + 'Long prompt repro', + '--display-name', + 'Long prompt repro' + ], + { cwd } + ) + taskId = readNested(task, ['task', 'id']) + if (!taskId) { + throw new Error('Could not create orchestration task') + } + + const dispatch = await callOrca( + cli, + [ + 'orchestration', + 'dispatch', + '--task', + taskId, + '--to', + workerHandle, + '--from', + coordinatorHandle, + '--inject' + ], + { cwd } + ) + const dispatchId = readNested(dispatch, ['dispatch', 'id']) + const report = await waitForReport(workerReportPath, timeoutMs) + const summary = { + mode, + sizeKb, + taskId, + dispatchId, + coordinatorHandle, + workerHandle, + reportPath: workerReportPath, + expectedMarker: marker, + expectedSpecBytes: Buffer.byteLength(spec, 'utf8'), + ...report + } + console.log(JSON.stringify(summary, null, 2)) + if (report.contractOk !== true) { + process.exitCode = 1 + } + } finally { + if (!keepTerminals) { + await tryCloseTerminal(cli, workerHandle, cwd) + await tryCloseTerminal(cli, coordinatorHandle, cwd) + } + if (discardReport) { + await rm(tempDir, { recursive: true, force: true }) + } + } +} + +function countUnframedLineBreaks(text) { + let inPaste = false + let count = 0 + for (let index = 0; index < text.length; index += 1) { + if (text.startsWith(BEGIN, index)) { + inPaste = true + index += BEGIN.length - 1 + continue + } + if (text.startsWith(END, index)) { + inPaste = false + index += END.length - 1 + continue + } + const char = text[index] + if (!inPaste && (char === '\n' || char === '\r')) { + count += 1 + } + } + return count +} + +function buildReport(payload, mode, marker) { + const firstSubmit = payload.indexOf(0x0d) + const body = firstSubmit === -1 ? payload : payload.subarray(0, firstSubmit) + const text = body.toString('utf8') + const hasBracketedPasteFrame = text.includes(BEGIN) && text.includes(END) + const hasSubmit = firstSubmit !== -1 + const rawContainsMarker = text.includes(marker) + const unframedLineBreaks = countUnframedLineBreaks(text) + const base = { + mode, + receivedBytesBeforeSubmit: body.length, + receivedSha256BeforeSubmit: createHash('sha256').update(body).digest('hex'), + hasSubmit, + rawContainsMarker, + hasBracketedPasteFrame, + unframedLineBreaks, + previewStart: text.slice(0, 120), + previewEnd: text.slice(-120) + } + if (mode === 'wire') { + return { + ...base, + contractOk: hasSubmit && rawContainsMarker + } + } + return { + ...base, + // Codex/Claude-like TUIs need generated multi-line prompts to arrive as + // one paste frame; unframed newlines are treated as live editor keys. + contractOk: hasSubmit && rawContainsMarker && hasBracketedPasteFrame && unframedLineBreaks === 0 + } +} + +function parseInjectedIds(text) { + return { + taskId: /Your task ID is:\s*(task_[A-Za-z0-9_]+)/.exec(text)?.[1] ?? null, + dispatchId: /--dispatch-id\s+(ctx_[A-Za-z0-9_]+)/.exec(text)?.[1] ?? null, + coordinatorHandle: /Your coordinator's terminal handle is:\s*([^\s]+)/.exec(text)?.[1] ?? null + } +} + +async function fakeWorkerMain() { + const cli = argValue('cli', process.env.ORCA_REPRO_CLI ?? 'orca') + const mode = argValue('mode', DEFAULT_MODE) + const reportPath = argValue('report') + const marker = argValue('marker') + const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) + if (!reportPath || !marker) { + throw new Error('--fake-worker requires --report and --marker') + } + + process.stdout.write('\x1b]0;codex ready\x07') + process.stdout.write('fake codex ready\n> ') + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + + const chunks = [] + let finished = false + async function sendWorkerDone(ids, report) { + if (!ids.taskId || !ids.dispatchId || !ids.coordinatorHandle) { + return { attempted: false, reason: 'missing-dispatch-identifiers' } + } + try { + await callOrca(cli, [ + 'orchestration', + 'send', + '--to', + ids.coordinatorHandle, + '--type', + 'worker_done', + '--subject', + report.contractOk + ? 'Harness observed safe prompt delivery' + : 'Harness reproduced unsafe prompt delivery', + '--body', + [ + `The fake worker received ${report.receivedBytesBeforeSubmit} bytes before submit.`, + `Bracketed paste frame present: ${String(report.hasBracketedPasteFrame)}.`, + `Unframed line breaks: ${String(report.unframedLineBreaks)}.` + ].join(' '), + '--task-id', + ids.taskId, + '--dispatch-id', + ids.dispatchId, + '--report-path', + reportPath + ]) + return { attempted: true, ok: true } + } catch (error) { + return { + attempted: true, + ok: false, + error: error instanceof Error ? error.message : String(error) + } + } + } + + const finish = async (reason) => { + if (finished) { + return + } + finished = true + const payload = Buffer.concat(chunks) + const report = { + reason, + ...buildReport(payload, mode, marker) + } + const ids = parseInjectedIds(payload.toString('utf8')) + report.parsedTaskId = ids.taskId + report.parsedDispatchId = ids.dispatchId + report.parsedCoordinatorHandle = ids.coordinatorHandle + report.workerDone = await sendWorkerDone(ids, report) + await writeFile(reportPath, JSON.stringify(report, null, 2)) + process.stdout.write( + `\nORCA_REPRO_REPORT ${report.contractOk ? 'ok' : 'failed'} ${reportPath}\n` + ) + process.exit(report.contractOk ? 0 : 7) + } + + const timer = setTimeout(() => { + finish('timeout').catch(() => process.exit(8)) + }, timeoutMs) + process.stdin.on('data', (chunk) => { + chunks.push(Buffer.from(chunk)) + if (Buffer.concat(chunks).includes(0x0d)) { + clearTimeout(timer) + finish('submit').catch(() => process.exit(8)) + } + }) +} + +function fakeCoordinatorMain() { + process.stdout.write('\x1b]0;orchestration repro coordinator\x07') + process.stdout.write('orchestration repro coordinator ready\n') + setInterval(() => {}, 60_000) +} + +async function main() { + if (hasFlag('help')) { + console.log(`Usage: + node tools/repro-orchestration-long-prompt.mjs [--mode codex-like|wire] [--size-kb 80] + +The parent mode requires a running Orca runtime and creates temporary Orca +terminals. The fake worker records whether orchestration dispatch delivered a +long prompt in a safe agent-input contract. + +Options: + --cli Orca CLI command (default: ORCA_REPRO_CLI or orca) + --worktree Worktree path for temporary terminals (default: cwd) + --timeout-ms Wait budget for terminal/report operations + --keep-terminals Leave temporary terminals open for inspection + --discard-report Delete the temporary JSON report after printing summary +`) + return + } + if (hasFlag('fake-worker')) { + await fakeWorkerMain() + return + } + if (hasFlag('fake-coordinator')) { + fakeCoordinatorMain() + return + } + await parentMain() +} + +main().catch((error) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : error) + process.exit(1) +}) From 2ce77d9098f8414eee4b28e165a38abadf781062 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 7 Jul 2026 23:45:55 -0400 Subject: [PATCH 055/152] fix(runtime): keep same-path imports host-qualified (#7395) * fix(runtime): keep same-path imports host-qualified (#7018) * review: harden runtime repo host match against SSH-repo hijack An unstamped repo with a connectionId is an SSH repo (resolves to ssh:), so a same-path runtime import must not adopt it into a runtime/local host. Match/adopt an unstamped repo only when it has no connectionId, mirroring the existing local-IPC dedup guard (src/main/ipc/repos.ts). Adds a regression test that fails without the guard (SSH repo hijacked into runtime host). Co-authored-by: Orca * review: only runtime hosts backfill an unstamped repo A legacy unstamped repo is indistinguishable from a genuine local repo (both have null executionHostId and connectionId). Restrict the adoption branch to runtime incoming hosts so a local/ssh import at a colliding path can never re-attribute a real local project to the wrong host. Runtime is the only host that lost its identity to the pre-#7018 path-only import and needs the backfill. Adds a regression test. Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/persistence.test.ts | 10 + src/main/persistence.ts | 1 + src/main/runtime/orca-runtime.test.ts | 402 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 76 ++++- 4 files changed, 480 insertions(+), 9 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ca2248cbeaa..956711371b1 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2988,6 +2988,16 @@ describe('Store', () => { expect(updated?.projectGroupOrder).toBe(1) }) + it('updates repo execution host identity', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + + const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' }) + + expect(updated?.executionHostId).toBe('runtime:env-1') + expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1') + }) + it('getRepo returns undefined for nonexistent id', async () => { const store = await createStore() expect(store.getRepo('nonexistent')).toBeUndefined() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 4ff4971933b..2caa3de28e1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4272,6 +4272,7 @@ export class Store { | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' + | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 62e9cbe7d62..5836b727795 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -66,6 +67,7 @@ import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -5255,6 +5257,406 @@ describe('OrcaRuntimeService', () => { } }) + it('keeps existing-folder imports split by runtime host on the same normalized path', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-')) + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' }) + const first = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + path: tempRoot, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + const second = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-2', + path: tempRoot, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + + expect(repos).toHaveLength(2) + expect(repos).toEqual([ + expect.objectContaining({ + path: tempRoot, + executionHostId: 'runtime:env-1' + }), + expect.objectContaining({ + path: tempRoot, + executionHostId: 'runtime:env-2' + }) + ]) + expect(first.repo).toMatchObject({ + path: tempRoot, + executionHostId: 'runtime:env-1' + }) + expect(first.setup).toMatchObject({ + repoId: first.repo.id, + hostId: 'runtime:env-1' + }) + expect(second.repo).toMatchObject({ + path: tempRoot, + executionHostId: 'runtime:env-2' + }) + expect(second.setup).toMatchObject({ + repoId: second.repo.id, + hostId: 'runtime:env-2' + }) + expect(first.repo.id).not.toBe(second.repo.id) + expect(first.setup.repoId).not.toBe(second.setup.repoId) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('keeps path-only runtime addRepo reuse working when a host-qualified repo already exists', async () => { + const repos: Record[] = [ + { + id: 'repo-runtime-1', + path: '/tmp/runtime-shared', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder', + executionHostId: 'runtime:env-1' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/tmp/runtime-shared', 'folder') + + expect(repo).toMatchObject({ + id: 'repo-runtime-1', + path: '/tmp/runtime-shared', + executionHostId: 'runtime:env-1' + }) + expect(repos).toHaveLength(1) + }) + + it('does not hijack a legacy SSH repo at the same path into a runtime host', async () => { + // A legacy SSH repo resolves to `ssh:` even with a null executionHostId, + // so a same-path runtime import must create a new repo instead of adopting it. + const repos: Record[] = [ + { + id: 'repo-ssh-1', + path: '/workspace', + displayName: 'workspace', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder', + connectionId: 'ssh-target-1' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/workspace', 'folder', 'runtime:env-1') + + expect(repos).toHaveLength(2) + expect(repo.id).not.toBe('repo-ssh-1') + expect(repo).toMatchObject({ path: '/workspace', executionHostId: 'runtime:env-1' }) + // The legacy SSH repo must be untouched (no executionHostId stamped onto it). + expect(repos[0]).toMatchObject({ id: 'repo-ssh-1', connectionId: 'ssh-target-1' }) + expect(repos[0]).not.toHaveProperty('executionHostId') + }) + + it('only a runtime host adopts an unstamped repo; local/ssh imports never stamp it', async () => { + // A genuine local repo is indistinguishable from a legacy runtime repo (both have null + // executionHostId and connectionId). Only a runtime host may backfill it — a local or ssh + // import at the same path must leave it untouched rather than re-attribute it to that host. + for (const importHostId of ['local', 'ssh:ssh-target-9'] as const) { + const repos: Record[] = [ + { + id: 'repo-local-1', + path: '/workspace', + displayName: 'workspace', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/workspace', 'folder', importHostId) + + // The matched repo is returned unchanged — no new repo, no executionHostId stamped. + expect(repos).toHaveLength(1) + expect(repo.id).toBe('repo-local-1') + expect(repos[0]).not.toHaveProperty('executionHostId') + } + }) + + it('keeps project clone setup on the cloned host-qualified repo', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const result = await runtime.setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + url: 'https://example.com/orca.git', + destination + }) + + expect(repos).toHaveLength(1) + expect(result.repo).toMatchObject({ + path: clonePath, + executionHostId: 'runtime:env-1', + projectHostSetupMethod: 'cloned' + }) + expect(result.setup).toMatchObject({ + repoId: result.repo.id, + hostId: 'runtime:env-1', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('adopts public clone repos into host-qualified project setup', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const cloned = await runtime.cloneRepo('https://example.com/orca.git', destination) + expect(cloned).toMatchObject({ + path: clonePath + }) + expect(cloned).not.toHaveProperty('executionHostId') + + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + path: clonePath, + kind: 'git', + setupMethod: 'cloned' + }) + + expect(repos).toHaveLength(1) + expect(result.repo).toMatchObject({ + id: cloned.id, + path: clonePath, + executionHostId: 'runtime:env-1', + projectHostSetupMethod: 'cloned' + }) + expect(result.setup).toMatchObject({ + repoId: cloned.id, + hostId: 'runtime:env-1', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('keeps project clone repos split by runtime host on the same clone path', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [ + { + id: 'repo-host-a', + path: clonePath, + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const result = await runtime.setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-2', + url: 'https://example.com/orca.git', + destination + }) + + expect(repos).toHaveLength(2) + expect(repos[0]).toMatchObject({ + id: 'repo-host-a', + executionHostId: 'runtime:env-1' + }) + expect(result.repo).toMatchObject({ + path: clonePath, + executionHostId: 'runtime:env-2' + }) + expect(result.repo.id).not.toBe('repo-host-a') + expect(result.setup).toMatchObject({ + repoId: result.repo.id, + hostId: 'runtime:env-2', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { const added: Record[] = [] const colorStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12b6d6036d9..1296ef45df8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -126,6 +126,7 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' +import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' @@ -1414,6 +1415,22 @@ function getRuntimeFolderWorkspaceRootId(repo: Repo): string { return `${repo.id}::${repo.path}` } +// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime +// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo +// (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. +function runtimeRepoMatchesExecutionHost( + repo: Pick, + executionHostId?: ExecutionHostId | null +): boolean { + if (executionHostId == null) { + return true + } + if (repo.executionHostId != null) { + return repo.executionHostId === executionHostId + } + return repo.connectionId == null +} + function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } @@ -9873,7 +9890,7 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } - let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git') + let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { const existingProject = this.listProjects().find((project) => project.id === args.projectId) @@ -9912,7 +9929,7 @@ export class OrcaRuntimeService { } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { - const repo = await this.cloneRepo(args.url, args.destination) + const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId, hostId: args.hostId, @@ -10313,7 +10330,11 @@ export class OrcaRuntimeService { }) } - async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise { + async addRepo( + path: string, + kind: 'git' | 'folder' = 'git', + executionHostId?: ExecutionHostId | null + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -10326,8 +10347,29 @@ export class OrcaRuntimeService { throw new Error(`Not a valid git repository: ${path}`) } - const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path)) + const existing = this.store.getRepos().find((repo) => { + if (!runtimePathsEqual(repo.path, path)) { + return false + } + return runtimeRepoMatchesExecutionHost(repo, executionHostId) + }) if (existing) { + // Only a runtime host backfills a legacy unstamped repo. An unstamped repo is + // indistinguishable from a genuine local repo (both have null executionHostId and + // connectionId), so we never stamp local/ssh onto it — that would re-attribute a + // real local project to the wrong host. Runtime is the only host that lost its + // identity to the pre-#7018 path-only import and needs the backfill. + if ( + existing.executionHostId == null && + parseExecutionHostId(executionHostId)?.kind === 'runtime' + ) { + const adopted = + this.store.updateRepo(existing.id, { executionHostId }) ?? + ({ ...existing, executionHostId } as Repo) + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return adopted + } return existing } @@ -10337,6 +10379,7 @@ export class OrcaRuntimeService { path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind, @@ -10475,7 +10518,11 @@ export class OrcaRuntimeService { return { repo: this.store.getRepo(repo.id) ?? repo } } - async cloneRepo(url: string, destination: string): Promise { + async cloneRepo( + url: string, + destination: string, + executionHostId?: ExecutionHostId | null + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -10503,7 +10550,8 @@ export class OrcaRuntimeService { trimmedUrl, trimmedDestination, clonePath, - clonePathKey + clonePathKey, + executionHostId ) } finally { release() @@ -10517,14 +10565,19 @@ export class OrcaRuntimeService { trimmedUrl: string, trimmedDestination: string, clonePath: string, - clonePathKey: string + clonePathKey: string, + executionHostId?: ExecutionHostId | null ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const existingBeforeClone = this.store .getRepos() - .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { return existingBeforeClone } @@ -10584,7 +10637,11 @@ export class OrcaRuntimeService { const existing = this.store .getRepos() - .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) if (existing) { if (isFolderRepo(existing)) { const updated = this.store.updateRepo(existing.id, { kind: 'git' }) @@ -10605,6 +10662,7 @@ export class OrcaRuntimeService { path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind: 'git', From a09a00f066550e005fc678fd4f63cd50eb4a129a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 8 Jul 2026 00:24:08 -0400 Subject: [PATCH 056/152] fix(pty): load omp status extension in wsl shells (#7642) Co-authored-by: Orca Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/ipc/pty.test.ts | 64 +++++++++++++++---- src/main/ipc/pty.ts | 15 ++--- src/main/providers/local-pty-shell-ready.ts | 23 ++++--- src/main/providers/windows-shell-args.test.ts | 44 ++++++++++++- src/main/providers/windows-shell-args.ts | 2 + src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 4 +- src/shared/wsl-login-shell-command.test.ts | 8 +++ src/shared/wsl-login-shell-command.ts | 17 +++++ 9 files changed, 148 insertions(+), 31 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 172114ce6aa..d54fd316271 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -18,11 +18,13 @@ const expectedOmpStatusExtension = posix.join( 'extensions', 'orca-agent-status.ts' ) -const expectedAttributionShimDir = join( - '/tmp/orca-user-data', - 'orca-terminal-attribution', - isWindowsHost ? 'win32' : 'posix' -) +function expectedAttributionShimDir(): string { + return join( + '/tmp/orca-user-data', + 'orca-terminal-attribution', + process.platform === 'win32' ? 'win32' : 'posix' + ) +} const { handleMock, @@ -1152,7 +1154,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca ') expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('skips git/gh attribution shims when attribution is disabled', async () => { @@ -1164,7 +1166,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined() expect(env.ORCA_GH_PR_FOOTER).toBeUndefined() expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => { @@ -1193,7 +1195,7 @@ describe('registerPtyHandlers', () => { const env = daemonSpawn.mock.calls.at(-1)![0].env expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { @@ -1799,7 +1801,7 @@ describe('registerPtyHandlers', () => { const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1829,7 +1831,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: true })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { @@ -1850,7 +1852,7 @@ describe('registerPtyHandlers', () => { ) expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1967,7 +1969,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: false })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('does not mutate the caller-provided args.env on the daemon path', async () => { @@ -5810,9 +5812,11 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', @@ -5822,6 +5826,42 @@ describe('registerPtyHandlers', () => { ) }) + it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_wsl'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + try { + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + env: { + ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(spawnCall[0]).toBe('wsl.exe') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f63b43719f0..bdd4cc13b77 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -909,15 +909,14 @@ export function buildPtyHostEnv( baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath } - // Why: in dev mode the `orca` CLI defaults to the production userData - // path, which routes status updates to the packaged Orca instead of this - // dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from - // agents running inside dev terminals reach the correct runtime. We also - // prepend the dev CLI launcher directory to PATH so `orca` resolves to - // the dev build (which supports ORCA_USER_DATA_PATH) instead of the - // production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + } else if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') const inheritedPath = readInheritedPath(baseEnv) // Why: avoid a trailing delimiter when PATH is empty — some shells diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 978d3e94b2a..99d52002dec 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -50,6 +50,10 @@ export type ShellReadySignal = { // ── Shell wrapper files ───────────────────────────────────────────── function getShellReadyWrapperRoot(): string { + // Why: this instance's userData must win over an inherited + // ORCA_USER_DATA_PATH (Orca launched from another Orca's terminal), so the + // wrapper writer root always matches the root buildPtyHostEnv hands to + // WSL children. const userDataPath = app?.getPath?.('userData') ?? process.env.ORCA_USER_DATA_PATH ?? tmpdir() return `${userDataPath}/shell-ready` } @@ -64,8 +68,8 @@ function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): s ] } -function shellReadyWrappersExist(): boolean { - return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) +function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { + return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) } // Why: if our own process inherited ZDOTDIR from a parent shell that was @@ -288,16 +292,12 @@ fi ` } -function ensureShellReadyWrappers(): void { - if (process.platform === 'win32') { - return - } - if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { +export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void { + if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) { return } didEnsureShellReadyWrappers = true - const root = getShellReadyWrapperRoot() const zshDir = `${root}/zsh` const bashDir = `${root}/bash` @@ -365,6 +365,13 @@ ${getZshFinalZdotdirRestoreBlock()} } } +function ensureShellReadyWrappers(): void { + if (process.platform === 'win32') { + return + } + ensureShellReadyWrappersAt() +} + // ── Shell launch config ───────────────────────────────────────────── export type ShellReadyLaunchConfig = { diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index d68f4014b23..7d0eeb5b4e3 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -17,6 +20,24 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] { } describe('resolveWindowsShellLaunchArgs', () => { + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) @@ -201,6 +222,27 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('materializes shell-ready wrappers before building WSL shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) + expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) + + // Why: the point of materializing wrappers for WSL is that a typed `omp` + // picks up Orca's status extension; pin that shim end to end. + const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + for (const wrapperFile of [bashRcfile, zshLogin]) { + expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') + expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + } + }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 98b119d8597..3889edfc5c1 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -6,6 +6,7 @@ import { escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' +import { ensureShellReadyWrappersAt } from './local-pty-shell-ready' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -94,6 +95,7 @@ function getPowerShellEncodedCommand(startupCommand?: string): { * Builds wsl.exe arguments that enter the target directory through the distro shell. */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { + ensureShellReadyWrappersAt() const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, 'export PATH="$HOME/.local/bin:$PATH"', diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index ec210e5db3b..9d7c36c6cfb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => { it('marks OMP status and hook env for Windows to WSL import', () => { const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', @@ -36,6 +37,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') + expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 2e4a878f52b..40a694a87ed 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): void { const entries = parseWslenvEntries(env.WSLENV) - // Why: wsl.exe only imports selected Windows env vars. Agent status in WSL - // needs both the pane identity and the hook/OMP coordinates at process start. + // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 925937c8811..7cc30477126 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -117,6 +117,14 @@ describe('wsl login shell command helpers', () => { expect(command).toContain('getent passwd') expect(command).toContain('if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then') + expect(command).toContain('_orca_shell_ready_root=""') + expect(command).toContain('if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then') + expect(command).toContain('_orca_wsl_shell_name=$(basename "$_orca_wsl_shell"') + expect(command).toContain('bash)') + expect(command).toContain('--rcfile "${_orca_shell_ready_root}/bash/rcfile"') + expect(command).toContain('zsh)') + expect(command).toContain('export ZDOTDIR="${_orca_shell_ready_root}/zsh"') expect(command).toContain('exec "$_orca_wsl_shell" -l') + expectValidShSyntax(command) }) }) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 45356f4020f..d95939eaa36 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -45,6 +45,23 @@ export function buildWslInteractiveLoginShellCommand(): string { 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', ' _orca_wsl_shell=/bin/sh', 'fi', + '_orca_shell_ready_root=""', + 'if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then', + ' _orca_shell_ready_root="${ORCA_USER_DATA_PATH%/}/shell-ready"', + 'fi', + '_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")', + 'case "$_orca_wsl_shell_name" in', + ' bash)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -f "${_orca_shell_ready_root}/bash/rcfile" ]; then', + ' exec "$_orca_wsl_shell" --rcfile "${_orca_shell_ready_root}/bash/rcfile"', + ' fi', + ' ;;', + ' zsh)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -d "${_orca_shell_ready_root}/zsh" ]; then', + ' export ZDOTDIR="${_orca_shell_ready_root}/zsh"', + ' fi', + ' ;;', + 'esac', 'exec "$_orca_wsl_shell" -l' ].join('\n') } From c0091c07c38bf6b7db7b5739f957a3163ea4948e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:42:12 -0700 Subject: [PATCH 057/152] perf(ssh): visibility-gate and back off the remote port scanner (#7610) Co-authored-by: Orca --- src/main/index.ts | 6 + src/main/ssh/ssh-port-scanner.test.ts | 261 ++++++++++++++++++ src/main/ssh/ssh-port-scanner.ts | 76 ++++- src/main/ssh/ssh-relay-session.test.ts | 4 + src/main/ssh/ssh-relay-session.ts | 9 +- .../window/main-window-visibility.test.ts | 28 ++ src/main/window/main-window-visibility.ts | 38 +++ 7 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 src/main/ssh/ssh-port-scanner.test.ts create mode 100644 src/main/window/main-window-visibility.test.ts create mode 100644 src/main/window/main-window-visibility.ts diff --git a/src/main/index.ts b/src/main/index.ts index e9270e16478..e6b0323b4fd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -111,6 +111,7 @@ import { import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { createSystemTray, destroySystemTray } from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' +import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import { @@ -986,6 +987,11 @@ function openMainWindow(): BrowserWindow { window.on('restore', resumeSyntheticTitleSpinnerTimer) window.on('hide', stopSyntheticTitleSpinnerTimer) window.on('minimize', stopSyntheticTitleSpinnerTimer) + // Why: visibility-gated main-process pollers (SSH port scanner) park while + // hidden and rely on this signal to resume; re-wired per window because + // macOS dock re-activation recreates the BrowserWindow. + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) agentHookServer.setListener( ({ paneKey, diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts new file mode 100644 index 00000000000..09be7c1f686 --- /dev/null +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PortScanner, + SSH_PORT_SCAN_BASE_INTERVAL_MS, + SSH_PORT_SCAN_MAX_INTERVAL_MS, + type PortScannerWindowVisibility +} from './ssh-port-scanner' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { DetectedPort } from '../../shared/ssh-types' + +type VisibilityHarness = { + visibility: PortScannerWindowVisibility + setVisible: (visible: boolean) => void + listenerCount: () => number +} + +function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness { + let visible = initiallyVisible + const listeners = new Set<() => void>() + return { + visibility: { + isWindowVisible: () => visible, + onWindowBecameVisible: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }, + setVisible: (next) => { + const wasVisible = visible + visible = next + if (!wasVisible && next) { + for (const listener of Array.from(listeners)) { + listener() + } + } + }, + listenerCount: () => listeners.size + } +} + +function port(portNumber: number): DetectedPort { + return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' } +} + +function createMux(ports: () => DetectedPort[]): { + mux: SshChannelMultiplexer + request: ReturnType +} { + const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' })) + return { mux: { request } as unknown as SshChannelMultiplexer, request } +} + +const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS +const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS + +describe('PortScanner', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => { + const harness = createVisibilityHarness(false) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(request).not.toHaveBeenCalled() + + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + + scanner.dispose() + }) + + it('polls at the base cadence while visible when results keep changing', async () => { + const harness = createVisibilityHarness(true) + let next = 3000 + const { mux, request } = createMux(() => [port(next++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + + // 5 simulated base intervals -> 5 more scans (no backoff while changing). + await vi.advanceTimersByTimeAsync(5 * BASE) + expect(request).toHaveBeenCalledTimes(6) + + scanner.dispose() + }) + + it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => { + const harness = createVisibilityHarness(true) + let ports = [port(3000)] + const { mux, request } = createMux(() => ports) + const onChanged = vi.fn() + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, onChanged) + + // t=0: first scan is a change (empty -> {3000}) and stays at base. + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledTimes(1) + + // t=12s: unchanged -> next wait doubles to 24s. + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + // t=24s: nothing (waiting until t=36s). + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + // t=36s: unchanged -> next wait caps at 30s (not 48s). + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(3) + + // t=65.999s: still waiting. + await vi.advanceTimersByTimeAsync(MAX - 1) + expect(request).toHaveBeenCalledTimes(3) + + // t=66s: unchanged -> next wait remains capped at 30s. + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(4) + + await vi.advanceTimersByTimeAsync(MAX - 1) + expect(request).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(5) + expect(onChanged).toHaveBeenCalledTimes(1) + + // A changed result resets the cadence back to base. + ports = [port(3000), port(4000)] + await vi.advanceTimersByTimeAsync(MAX) + expect(request).toHaveBeenCalledTimes(6) + expect(onChanged).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(7) + expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000]) + + scanner.dispose() + }) + + it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => { + const harness = createVisibilityHarness(true) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + const onChanged = vi.fn() + scanner.startScanning('t1', mux, onChanged) + + // Back off: scans at t=0, t=12s, t=36s -> interval now 48s. + await vi.advanceTimersByTimeAsync(3 * BASE) + expect(request).toHaveBeenCalledTimes(3) + + // Reconnect paths call startScanning again: immediate scan, base cadence. + scanner.startScanning('t1', mux, onChanged) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(5) + + scanner.dispose() + }) + + it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => { + const harness = createVisibilityHarness(true) + let next = 3000 + const { mux, request } = createMux(() => [port(next++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + harness.setVisible(false) + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(request).toHaveBeenCalledTimes(2) + + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(4) + + scanner.dispose() + }) + + it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => { + const harness = createVisibilityHarness(true) + let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null + const request = vi.fn( + () => + new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => { + resolveFirst = resolve + }) + ) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn()) + expect(request).toHaveBeenCalledTimes(1) + + // The chain waits for the in-flight request instead of stacking more. + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(1) + + resolveFirst!({ ports: [port(3000)], platform: 'linux' }) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + scanner.dispose() + }) + + it('stopScanning halts polling and detaches the visibility listener', async () => { + const harness = createVisibilityHarness(true) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + expect(harness.listenerCount()).toBe(1) + + scanner.stopScanning('t1') + expect(harness.listenerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(10 * 60_000) + harness.setVisible(false) + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(request).toHaveBeenCalledTimes(1) + expect(scanner.getDetectedPorts('t1')).toEqual([]) + }) + + it('keeps targets independent: stopping one host leaves the other scanning', async () => { + const harness = createVisibilityHarness(true) + let nextA = 3000 + let nextB = 4000 + const a = createMux(() => [port(nextA++)]) + const b = createMux(() => [port(nextB++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('host-a', a.mux, vi.fn()) + scanner.startScanning('host-b', b.mux, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(a.request).toHaveBeenCalledTimes(1) + expect(b.request).toHaveBeenCalledTimes(1) + + scanner.stopScanning('host-a') + await vi.advanceTimersByTimeAsync(BASE) + expect(a.request).toHaveBeenCalledTimes(1) + expect(b.request).toHaveBeenCalledTimes(2) + + scanner.dispose() + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(b.request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) +}) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 3746308db36..6404d035f22 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -1,10 +1,30 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' -const POLL_INTERVAL_MS = 3_000 +// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote +// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the +// old 3s while keeping new-port detection well under the 30s workspace-scanner +// cadence users already accept. +export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000 +// Why: idle backoff cap — an unchanged port set doubles the interval up to +// this bound, so a quiet remote costs two scans per minute instead of twenty +// without exceeding the existing workspace-port scanner's visible cadence. +export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000 + +export type PortScannerWindowVisibility = { + isWindowVisible: () => boolean + // Fires when a hidden/minimized window is shown again; returns unsubscribe. + onWindowBecameVisible: (listener: () => void) => () => void +} type ScanHandle = { - timer: ReturnType + timer: ReturnType | null + intervalMs: number + // Why: while the window is hidden the scan chain is parked outright — no + // timer wakeups, no remote requests — and the visibility listener resumes + // it with an immediate scan so ports opened while hidden surface at once. + parkedWhileHidden: boolean + unsubscribeVisibility: () => void // Why: keyed by "host:port" (not just port) so that host-distinct listeners // on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately. previousPorts: Map @@ -17,6 +37,8 @@ type ScanHandle = { export class PortScanner { private handles = new Map() + constructor(private visibility: PortScannerWindowVisibility) {} + startScanning( targetId: string, mux: SshChannelMultiplexer, @@ -25,14 +47,18 @@ export class PortScanner { this.stopScanning(targetId) const handle: ScanHandle = { - timer: null!, + timer: null, + intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, + parkedWhileHidden: false, + unsubscribeVisibility: () => {}, previousPorts: new Map(), initialPorts: null } + const isCurrent = (): boolean => this.handles.get(targetId) === handle - // Why: guard against overlapping scans. On slow remotes, /proc/*/fd walks - // can take longer than POLL_INTERVAL_MS. Without this guard, setInterval - // would stack up concurrent requests on the shared SSH multiplexer. + // Why: guard against overlapping scans. The timer chain only reschedules + // after a poll completes, but the visibility-resume path can race a poll + // still in flight on a slow remote. let polling = false const poll = async (): Promise => { if (polling) { @@ -45,7 +71,7 @@ export class PortScanner { platform: string } - if (!this.handles.has(targetId)) { + if (!isCurrent()) { return } @@ -60,7 +86,10 @@ export class PortScanner { if (!portsEqual(handle.previousPorts, currentPorts)) { handle.previousPorts = currentPorts + handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS onChanged(targetId, result.ports, result.platform) + } else { + handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) } } catch { // Relay disconnected or request timed out — retry on next interval @@ -69,10 +98,32 @@ export class PortScanner { } } - handle.timer = setInterval(() => void poll(), POLL_INTERVAL_MS) - this.handles.set(targetId, handle) + const tick = async (): Promise => { + handle.timer = null + if (!isCurrent()) { + return + } + if (!this.visibility.isWindowVisible()) { + handle.parkedWhileHidden = true + return + } + await poll() + if (!isCurrent()) { + return + } + handle.timer = setTimeout(() => void tick(), handle.intervalMs) + } - void poll() + handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => { + if (!isCurrent() || !handle.parkedWhileHidden) { + return + } + handle.parkedWhileHidden = false + void tick() + }) + + this.handles.set(targetId, handle) + void tick() } getDetectedPorts(targetId: string): DetectedPort[] { @@ -88,7 +139,10 @@ export class PortScanner { if (!handle) { return } - clearInterval(handle.timer) + if (handle.timer) { + clearTimeout(handle.timer) + } + handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 0dfa2edc8d8..614c121d56a 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -118,6 +118,10 @@ function createMockDeps() { } as unknown as SshPortForwardManager const mockWindow = { isDestroyed: () => false, + // Why: the port scanner visibility-gates its ticks; a visible mock window + // keeps establish-path tests exercising the scan-on-ready behavior. + isVisible: () => true, + isMinimized: () => false, webContents: { send: vi.fn() } } const getMainWindow = vi.fn().mockReturnValue(mockWindow) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2462bbd94c3..4b29d7f7ea3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -52,6 +52,7 @@ import { import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' import { PortScanner } from './ssh-port-scanner' +import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -982,7 +983,13 @@ export class SshRelaySession { if (!this.mux || this.isDisposed()) { return } - const scanner = new PortScanner() + // Why: each scan walks /proc/*/fd on the remote host, so the scanner skips + // ticks entirely while no window can show the results (hidden to tray or + // minimized overnight) and rescans immediately when the window returns. + const scanner = new PortScanner({ + isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), + onWindowBecameVisible: onMainWindowBecameVisible + }) this.portScanner = scanner // Why: capture the scanner instance so that a late ports.detect callback // from a previous relay session (before reconnect replaced it) is silently diff --git a/src/main/window/main-window-visibility.test.ts b/src/main/window/main-window-visibility.test.ts new file mode 100644 index 00000000000..335dc4a87d6 --- /dev/null +++ b/src/main/window/main-window-visibility.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { isMainWindowVisible } from './main-window-visibility' + +describe('main window visibility helpers', () => { + it('treats a minimal alive window double as visible', () => { + expect(isMainWindowVisible({ isDestroyed: () => false })).toBe(true) + }) + + it('parks work when the real window is hidden, minimized, destroyed, or missing', () => { + expect( + isMainWindowVisible({ + isDestroyed: () => false, + isVisible: () => false, + isMinimized: () => false + }) + ).toBe(false) + expect( + isMainWindowVisible({ + isDestroyed: () => false, + isVisible: () => true, + isMinimized: () => true + }) + ).toBe(false) + expect(isMainWindowVisible({ isDestroyed: () => true })).toBe(false) + expect(isMainWindowVisible(null)).toBe(false) + }) +}) diff --git a/src/main/window/main-window-visibility.ts b/src/main/window/main-window-visibility.ts new file mode 100644 index 00000000000..8b4ca4c4647 --- /dev/null +++ b/src/main/window/main-window-visibility.ts @@ -0,0 +1,38 @@ +// Why: BrowserWindow instances are recreated on macOS dock re-activation, so +// long-lived main-process services (e.g. the SSH port scanner) subscribe to +// this process-global signal instead of a specific window instance; index.ts +// re-wires each new window's show/restore events into notifyMainWindowBecameVisible. +type MainWindowBecameVisibleListener = () => void + +type MainWindowVisibilityState = { + isDestroyed: () => boolean + isVisible?: () => boolean + isMinimized?: () => boolean +} + +const listeners = new Set() + +export function notifyMainWindowBecameVisible(): void { + for (const listener of Array.from(listeners)) { + listener() + } +} + +export function onMainWindowBecameVisible(listener: MainWindowBecameVisibleListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function isMainWindowVisible(window: MainWindowVisibilityState | null): boolean { + if (window === null || window.isDestroyed()) { + return false + } + + // Why: production BrowserWindow exposes both APIs, but older main-process + // tests use minimal window doubles that should stay visible by default. + const isVisible = window.isVisible?.() ?? true + const isMinimized = window.isMinimized?.() ?? false + return isVisible && !isMinimized +} From 45931a716e0cfb9f57bd804b65d09baadd24f81c Mon Sep 17 00:00:00 2001 From: Ken Fukuyama Date: Wed, 8 Jul 2026 17:35:37 +0900 Subject: [PATCH 058/152] Allow browser annotations to target existing agents (#7347) * Allow browser annotations to target existing agents * Update browser annotation send tooltip * test: guard browser annotation send menu wiring --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../BrowserAnnotationSendMenuContent.test.tsx | 105 ++++++++++++++++++ .../BrowserAnnotationSendMenuContent.tsx | 29 +++++ .../components/browser-pane/BrowserPane.tsx | 17 +-- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 8 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx create mode 100644 src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.tsx diff --git a/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx b/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx new file mode 100644 index 00000000000..fec2f84c084 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' + +type ReactElementLike = { + type: unknown + props: Record +} + +vi.mock('@/components/editor/ReviewNotesSendMenuContent', () => ({ + ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record) { + return { type: 'ReviewNotesSendMenuContent', props } + } +})) + +function expand(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map((entry) => expand(entry)) + } + if (!React.isValidElement(node)) { + if (typeof node === 'object' && 'props' in node) { + const element = node as ReactElementLike + return { + ...element, + props: { + ...element.props, + children: expand(element.props.children) + } + } + } + return node + } + const element = node as React.ReactElement> + if (typeof element.type === 'function') { + const Component = element.type as (props: Record) => unknown + return expand(Component(element.props)) + } + return { + type: element.type, + props: { + ...element.props, + children: expand(element.props.children) + } + } +} + +function findByType(node: unknown, type: string): ReactElementLike { + if (node && typeof node === 'object' && 'type' in node) { + const element = node as ReactElementLike + if (element.type === type) { + return element + } + const children = element.props.children + if (Array.isArray(children)) { + for (const child of children) { + try { + return findByType(child, type) + } catch { + // Continue searching siblings. + } + } + } else if (children) { + return findByType(children, type) + } + } + throw new Error(`Unable to find ${type}`) +} + +describe('BrowserAnnotationSendMenuContent', () => { + it('uses the review notes send content so existing agent sessions are selectable', () => { + const onPromptDelivered = vi.fn() + const tree = expand( + + ) + + expect(findByType(tree, 'ReviewNotesSendMenuContent').props).toMatchObject({ + worktreeId: 'wt-1', + groupId: 'group-1', + prompt: 'browser annotation prompt', + promptDelivery: 'submit-after-ready', + launchSource: 'notes_send', + onPromptDelivered + }) + }) + + it('is wired into both browser annotation send surfaces', () => { + const browserPaneSource = readFileSync( + fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)), + 'utf8' + ) + + expect(browserPaneSource.match(/ void +} + +export function BrowserAnnotationSendMenuContent({ + worktreeId, + groupId, + prompt, + onPromptDelivered +}: BrowserAnnotationSendMenuContentProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 6931cf21465..0d75bc980a4 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -45,8 +45,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' import { DropdownMenu, DropdownMenuContent, @@ -5297,7 +5296,7 @@ function BrowserPagePane({ {translate( 'auto.components.browser.pane.BrowserPane.95af781091', - 'Send feedback to a new agent' + 'Send feedback to an agent' )} @@ -5307,13 +5306,10 @@ function BrowserPagePane({ onInteractOutside={preventAgentSendTargetOutsideDismiss} onPointerDownOutside={preventAgentSendTargetOutsideDismiss} > - @@ -5571,7 +5567,7 @@ function BrowserPagePane({ {translate( 'auto.components.browser.pane.BrowserPane.95af781091', - 'Send feedback to a new agent' + 'Send feedback to an agent' )} @@ -5581,13 +5577,10 @@ function BrowserPagePane({ onInteractOutside={preventAgentSendTargetOutsideDismiss} onPointerDownOutside={preventAgentSendTargetOutsideDismiss} > - diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a51b6f4c6a..01637ff30de 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11729,7 +11729,7 @@ "f2d0c22d67": "Delete annotation {{value0}}", "11c5084aa2": "Clear annotations", "734e4343ec": "Clear browser annotations", - "95af781091": "Send feedback to a new agent", + "95af781091": "Send feedback to an agent", "ac39b9366b": "Send", "a3508d7e6e": "{{value0}} annotation{{value1}} ready. Select another element or copy all feedback.", "f796c774a4": "Type a URL above to start browsing.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 95f2fd339b8..e9af23a0ceb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11729,7 +11729,7 @@ "f2d0c22d67": "Eliminar anotación {{value0}}", "11c5084aa2": "Borrar anotaciones", "734e4343ec": "Borrar anotaciones del navegador", - "95af781091": "Enviar comentarios a un nuevo agente", + "95af781091": "Enviar comentarios a un agente", "ac39b9366b": "Enviar", "a3508d7e6e": "{{value0}} anotación{{value1}} listo. Seleccione otro elemento o copie todos los comentarios.", "f796c774a4": "Escriba una URL arriba para comenzar a navegar.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 158ec815bd1..2aa5e241110 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11729,7 +11729,7 @@ "f2d0c22d67": "注釈 {{value0}} を削除", "11c5084aa2": "注釈をクリアする", "734e4343ec": "ブラウザの注釈をクリアする", - "95af781091": "新規 agent にフィードバックを送信する", + "95af781091": "agent にフィードバックを送信する", "ac39b9366b": "送信", "a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。", "f796c774a4": "上に URL を入力して閲覧を開始します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aba282e254e..dcbaadc3e27 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11729,7 +11729,7 @@ "f2d0c22d67": "주석 {{value0}} 삭제", "11c5084aa2": "주석 지우기", "734e4343ec": "브라우저 주석 지우기", - "95af781091": "새 agent에게 피드백 보내기", + "95af781091": "agent에게 피드백 보내기", "ac39b9366b": "보내기", "a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.", "f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 582ad7e30de..968e88e0ec1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11729,7 +11729,7 @@ "f2d0c22d67": "删除注释 {{value0}}", "11c5084aa2": "清晰的注释", "734e4343ec": "清除浏览器注释", - "95af781091": "向新智能体发送反馈", + "95af781091": "向智能体发送反馈", "ac39b9366b": "发送", "a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。", "f796c774a4": "在上面输入 URL 即可开始浏览。", From 14ac945c502c61d3dab959b0d523fe9eceb3f6cd Mon Sep 17 00:00:00 2001 From: 3499623985 <148999470+3499623985@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:24:41 +0800 Subject: [PATCH 059/152] fix(main): detect CJK input source via cfprefsd on macOS 15 (#7572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(main): detect CJK input source via cfprefsd on macOS 15 macOS 15's `plutil -extract json` aborts with "invalid object in plist for destination format" on the AppleSelectedInputSources array even though it is all strings, so the selected-input-source probe threw and fell back to the keyboard layout id (com.apple.keylayout.US). That disabled forwardAsciiPunctuation, so third-party IMEs (Sogou, Doubao) sent half-width ,.? to the PTY instead of full-width ,。? in terminal panes and agent chat. Apple's built-in IME happened not to trip the plutil bug. Read the live prefs via `defaults export` (cfprefsd) and extract as xml1 before converting the clean subtree to JSON, dodging both the plutil json bug and the stale on-disk plist. The parser and CJK term list are unchanged. * fix(main): reap CJK input-source probe process group on timeout Run the macOS input-source probe via detached spawn and SIGKILL the whole process group on timeout so a wedged cfprefsd can't orphan the defaults/plutil pipeline stages (the probe re-runs on every window focus-in). Pin absolute /usr/bin paths, guard the stdout stream, and cover the non-zero-exit, spawn- failure, and timeout fallbacks in tests. Co-authored-by: Orca --------- Co-authored-by: su'qiang Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- src/main/ipc/app.test.ts | 153 ++++++++++++++++++++++++++++++--------- src/main/ipc/app.ts | 87 ++++++++++++++-------- 2 files changed, 175 insertions(+), 65 deletions(-) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e1149d1b6d1..1eb9bbb17a2 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -5,7 +6,7 @@ const { appExitMock, appQuitMock, appRelaunchMock, - execFileMock, + spawnMock, destroySystemTrayMock, showOpenDialogMock, grantFloatingWorkspaceDirectoryMock @@ -14,16 +15,53 @@ const { appExitMock: vi.fn(), appQuitMock: vi.fn(), appRelaunchMock: vi.fn(), - execFileMock: vi.fn(), + spawnMock: vi.fn(), destroySystemTrayMock: vi.fn(), showOpenDialogMock: vi.fn(), grantFloatingWorkspaceDirectoryMock: vi.fn() })) vi.mock('node:child_process', () => ({ - execFile: execFileMock + spawn: spawnMock })) +// Fakes the detached `spawn` child: a stdout EventEmitter plus close/error +// events, so tests drive the async command lifecycle readCommandStdout expects. +function createFakeSpawnChild(options: { + stdout?: string + code?: number + error?: Error + pid?: number + hang?: boolean +}): EventEmitter & { pid: number; kill: ReturnType; stdout: EventEmitter } { + const { stdout, code = 0, error, pid = 4242, hang = false } = options + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter & { setEncoding: ReturnType } + } + child.pid = pid + child.kill = vi.fn() + const stdoutStream = new EventEmitter() as EventEmitter & { + setEncoding: ReturnType + } + stdoutStream.setEncoding = vi.fn() + child.stdout = stdoutStream + if (!hang) { + queueMicrotask(() => { + if (error) { + child.emit('error', error) + return + } + if (stdout !== undefined) { + stdoutStream.emit('data', stdout) + } + child.emit('close', code) + }) + } + return child +} + vi.mock('electron', () => ({ app: { exit: appExitMock, @@ -63,6 +101,9 @@ import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { const originalPlatform = process.platform + // Why: readCommandStdout process-group-kills on timeout; stub the real signal + // so a fake child pid can never target a live process group during tests. + let processKillSpy: ReturnType beforeEach(() => { vi.useFakeTimers() @@ -70,14 +111,16 @@ describe('registerAppHandlers', () => { appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() - execFileMock.mockReset() + spawnMock.mockReset() destroySystemTrayMock.mockReset() showOpenDialogMock.mockReset() grantFloatingWorkspaceDirectoryMock.mockReset() + processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true) Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) afterEach(() => { + processKillSpy.mockRestore() vi.useRealTimers() Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) @@ -177,10 +220,9 @@ describe('registerAppHandlers', () => { it('returns the selected macOS input mode before the keyboard layout fallback', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback( - null, - JSON.stringify([ + spawnMock.mockImplementation(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, { 'Bundle ID': 'com.apple.inputmethod.SCIM', @@ -188,60 +230,96 @@ describe('registerAppHandlers', () => { InputSourceKind: 'Input Mode' } ]) - ) - return { kill: vi.fn() } - }) + }) + ) registerAppHandlers({} as never) await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( 'com.apple.inputmethod.SCIM.ITABC' ) - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock).toHaveBeenCalledWith( - '/usr/bin/plutil', - expect.arrayContaining(['AppleSelectedInputSources']), - expect.any(Object), - expect.any(Function) + expect(spawnMock).toHaveBeenCalledTimes(1) + // Why: macOS 15's `plutil -extract json` aborts on the input-source + // array, so the probe reads live cfprefsd via `defaults export` and dodges + // the bug with an xml1 extract before converting the clean subtree to JSON. + // Pin the exact pipeline (absolute paths, stdin markers) so dropping any + // stage silently regressing CJK detection to the fallback fails the test. + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + [ + '-c', + '/usr/bin/defaults export com.apple.HIToolbox - | ' + + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + + '/usr/bin/plutil -convert json -o - -' + ], + expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) ) }) it('falls back to the keyboard layout when no keyboard input mode is selected', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => { - callback( - null, - JSON.stringify([ + spawnMock + .mockImplementationOnce(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' } ]) - ) - return { kill: vi.fn() } - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'com.apple.keylayout.ABC\n') - return { kill: vi.fn() } - }) + }) + ) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) registerAppHandlers({} as never) await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( 'com.apple.keylayout.ABC' ) - expect(execFileMock).toHaveBeenCalledTimes(2) - expect(execFileMock).toHaveBeenLastCalledWith( + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenLastCalledWith( '/usr/bin/defaults', ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.any(Object), - expect.any(Function) + expect.objectContaining({ detached: true }) ) }) + it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + // Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so + // the probe rejects on the `close` branch and the handler falls back. + spawnMock + .mockImplementationOnce(() => createFakeSpawnChild({ code: 1 })) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.keylayout.ABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenLastCalledWith( + '/usr/bin/defaults', + ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], + expect.objectContaining({ detached: true }) + ) + }) + + it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + // Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must + // still fall back rather than reject out of the IPC call. + spawnMock + .mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') })) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.keylayout.ABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + it('falls back when macOS keyboard input source probes never report completion', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) registerAppHandlers({} as never) const handler = handlers.get('app:getKeyboardInputSourceId') @@ -256,7 +334,10 @@ describe('registerAppHandlers', () => { expect(settled).toBe(true) await expect(resultPromise).resolves.toBeNull() - expect(killMock).toHaveBeenCalledTimes(2) + // Why: both wedged probes get a process-group SIGKILL (negative pid) so the + // shell and any orphaned `defaults`/`plutil` stages are reaped on timeout. + expect(processKillSpy).toHaveBeenCalledTimes(2) + expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') }) it('picks an existing floating workspace directory without enabling native directory creation', async () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 276c3ba5f71..2dc3c81f91b 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,6 +1,5 @@ -import { execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { homedir } from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron' @@ -24,12 +23,15 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' -const MAC_HITOOLBOX_PREFERENCES_PATH = path.join( - homedir(), - 'Library', - 'Preferences', - `${MAC_HITOOLBOX_DOMAIN}.plist` -) +// Why: macOS 15's `plutil -extract json` aborts on the (pure-string) +// input-source array and the on-disk plist lags cfprefsd; read live prefs via +// `defaults export`, extract as xml1 (dodges the json bug), then convert to JSON. +// Absolute paths so a GUI-launched app's minimal PATH can't shadow the tools. +const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ + `/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`, + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -', + '/usr/bin/plutil -convert json -o - -' +].join(' | ') type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -118,13 +120,32 @@ function readCommandStdout( ): Promise { return new Promise((resolve, reject) => { let settled = false - let child: ReturnType | undefined + let child: ReturnType | undefined + + // Why: the probe runs a `/bin/sh -c` pipeline, so signaling only the shell + // orphans wedged `defaults`/`plutil` stages on a stuck cfprefsd. Spawning + // detached makes the child a process-group leader, so one negative-pid + // SIGKILL reaps the shell and every stage; child.kill() covers the fallback. + const killTree = (): void => { + if (!child?.pid) { + return + } + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + child.kill() + } + } + + // Why: short timeout so a wedged macOS preference probe (corporate-managed + // config, sandbox policy, ...) never holds the handle indefinitely. This + // manual timer is the only timeout guard, so it owns the process-group kill. const timer = setTimeout(() => { if (settled) { return } settled = true - child?.kill() + killTree() reject(new Error(timeoutMessage)) }, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS) @@ -137,24 +158,32 @@ function readCommandStdout( callback() } - // Why: execFile's timeout only signals `defaults`; if the callback - // never arrives, window-focus keyboard probes would remain pending. try { - child = execFile( - command, - args, - // Why: short timeout so a wedged macOS preference probe (corporate-managed - // config, sandbox policy, ...) never holds the handle indefinitely. - // Fall through to the fingerprint on timeout. - { encoding: 'utf8', timeout: KEYBOARD_INPUT_SOURCE_TIMEOUT_MS }, - (error, stdout) => { - if (error) { - settle(() => reject(error)) - return - } - settle(() => resolve(String(stdout ?? ''))) - } - ) + child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let stdout = '' + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + }) + const failWith = (error: Error): void => { + killTree() + settle(() => reject(error)) + } + // Why: an unhandled Readable 'error' would crash the main process; treat a + // stdout read failure the same as a child spawn error. + child.stdout?.on('error', failWith) + child.on('error', failWith) + child.on('close', (code, signal) => { + settle(() => + code === 0 + ? resolve(stdout) + : reject( + new Error( + `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` + ) + ) + ) + }) } catch (error) { settle(() => reject(error)) } @@ -196,8 +225,8 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null { async function readSelectedKeyboardInputSourceId(): Promise { try { const stdout = await readCommandStdout( - '/usr/bin/plutil', - ['-extract', 'AppleSelectedInputSources', 'json', '-o', '-', MAC_HITOOLBOX_PREFERENCES_PATH], + '/bin/sh', + ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], 'Selected keyboard input source probe timed out' ) return readSelectedInputSourceIdFromJson(stdout) From 745765bb96d1b0d6700c78dcd36a4090ff8b3861 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:34:47 +0000 Subject: [PATCH 060/152] release: v1.4.129-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3a4d50c3e7..8d8b7f14251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.129-rc.0", + "version": "1.4.129-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From ea652a1ffe8043b2a4bebe88d8e6a9b410287732 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:50:39 +0000 Subject: [PATCH 061/152] 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 4afaca9293c..67a50e6b5b7 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.9m + + downloads: 4.0m @@ -15,7 +15,7 @@ downloads downloads - 3.9m - 3.9m + 4.0m + 4.0m From 38e82b4fff4414728743e2c5b4622671a642de45 Mon Sep 17 00:00:00 2001 From: Dhilip Subramanian <49802211+sdhilip200@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:20:26 +1200 Subject: [PATCH 062/152] Fix AI Vault scanning for runtime hosts (#7631) * Fix AI Vault runtime host scanning * fix runtime ai vault response validation * test runtime ai vault validation more loosely * fix(ai-vault): harden runtime host scanning Co-authored-by: Orca * fix(ai-vault): resume runtime host sessions Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../ai-vault/runtime-session-scanner.test.ts | 144 ++++++++++++++ src/main/ai-vault/runtime-session-scanner.ts | 176 ++++++++++++++++++ src/main/ai-vault/session-list-results.ts | 48 +++++ src/main/ipc/ai-vault.test.ts | 83 ++++++++- src/main/ipc/ai-vault.ts | 165 ++++++++++------ src/main/ipc/register-core-handlers.test.ts | 80 +++++++- src/main/ipc/register-core-handlers.ts | 11 +- src/main/runtime/rpc/methods/ai-vault.test.ts | 61 ++++++ src/main/runtime/rpc/methods/ai-vault.ts | 36 ++++ src/main/runtime/rpc/methods/index.ts | 2 + .../components/right-sidebar/AiVaultPanel.tsx | 44 +++-- .../right-sidebar/AiVaultPanelControls.tsx | 26 +-- .../right-sidebar/AiVaultPanelHeader.tsx | 7 +- .../right-sidebar/ai-vault-host-scope.test.ts | 80 +++++++- .../right-sidebar/ai-vault-host-scope.ts | 74 +++++++- .../ai-vault-session-launch-actions.ts | 45 +++-- .../ai-vault-session-resume.test.ts | 63 +++++-- .../tab-group/AiVaultSessionDropLayer.tsx | 42 +++-- src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../src/lib/ai-vault-resume-target.test.ts | 32 +++- .../src/lib/ai-vault-resume-target.ts | 18 +- .../src/lib/launch-ai-vault-session.test.ts | 58 ++++++ .../src/lib/launch-ai-vault-session.ts | 35 +++- src/renderer/src/web/web-preload-api.test.ts | 98 ++++++++++ src/renderer/src/web/web-preload-api.ts | 61 +++++- 29 files changed, 1345 insertions(+), 194 deletions(-) create mode 100644 src/main/ai-vault/runtime-session-scanner.test.ts create mode 100644 src/main/ai-vault/runtime-session-scanner.ts create mode 100644 src/main/ai-vault/session-list-results.ts create mode 100644 src/main/runtime/rpc/methods/ai-vault.test.ts create mode 100644 src/main/runtime/rpc/methods/ai-vault.ts diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts new file mode 100644 index 00000000000..ffb2a1c398f --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + listEnvironments: vi.fn() +})) + +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: mocks.listEnvironments +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) + +const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } = + await import('./runtime-session-scanner') + +beforeEach(() => { + vi.clearAllMocks() + mocks.listEnvironments.mockReturnValue([]) + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: result([session('runtime:env-1', 'session-1')]) + }) +}) + +describe('runtime AI Vault session scanner', () => { + it('lists saved runtime environments as runtime execution hosts', () => { + mocks.listEnvironments.mockReturnValue([ + { id: 'env-1', name: 'Server 1' }, + { id: 'env-2', name: 'Server 2' } + ]) + + expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([ + { environmentId: 'env-1', executionHostId: 'runtime:env-1' }, + { environmentId: 'env-2', executionHostId: 'runtime:env-2' } + ]) + }) + + it('forwards scan options to the runtime transport', async () => { + await scanRuntimeAiVaultSessions( + '/user-data', + 'env-1', + { + limit: 25, + force: true, + scopePaths: ['/srv/app'] + }, + { timeoutMs: 3000 } + ) + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'aiVault.listSessions', + { + limit: 25, + force: true, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:env-1' + }, + 3000 + ) + }) + + it('stamps sessions and issues returned for a different execution host', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: result( + [session('local', 'session-1')], + [ + { + executionHostId: 'ssh:dev-box', + agent: 'codex', + path: '/sessions/session-1.jsonl', + message: 'could not parse session' + } + ] + ) + }) + + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) + + expect(scanResult.sessions).toEqual([ + expect.objectContaining({ + id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl', + executionHostId: 'runtime:env-1' + }) + ]) + expect(scanResult.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'runtime:env-1', + agent: 'codex', + path: '/sessions/session-1.jsonl' + }) + ]) + }) + + it('stamps accepted sessions with the requested runtime host', async () => { + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) + + expect(scanResult.sessions).toEqual([ + expect.objectContaining({ + id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl', + executionHostId: 'runtime:env-1' + }) + ]) + }) +}) + +function result( + sessions: AiVaultSession[], + issues: AiVaultListResult['issues'] = [] +): AiVaultListResult { + return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' } +} + +function session( + executionHostId: AiVaultSession['executionHostId'], + sessionId: string +): AiVaultSession { + return { + id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`, + executionHostId, + executionHostPlatform: 'linux', + agent: 'codex', + sessionId, + title: sessionId, + cwd: '/srv/app', + branch: null, + model: null, + filePath: `/sessions/${sessionId}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: '2026-07-04T03:00:00.000Z', + modifiedAt: '2026-07-04T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + resumeCommand: `codex resume ${sessionId}` + } +} diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts new file mode 100644 index 00000000000..7a700892261 --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -0,0 +1,176 @@ +import { z } from 'zod' +import { + AI_VAULT_AGENTS, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultSession +} from '../../shared/ai-vault-types' +import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' + +export type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + +export type RuntimeAiVaultScanOptions = { + timeoutMs?: number +} + +const nodePlatformSchema = z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +] satisfies NodeJS.Platform[]) + +const aiVaultSessionPreviewMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), + text: z.string(), + timestamp: z.string().nullable() +}) + +const executionHostIdSchema = z.string().transform((value, ctx) => { + const normalized = normalizeExecutionHostId(value) + if (normalized) { + return normalized + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid execution host id' + }) + return z.NEVER +}) + +const aiVaultListResultSchema = z.object({ + sessions: z.array( + z.object({ + id: z.string(), + executionHostId: executionHostIdSchema, + executionHostPlatform: nodePlatformSchema.nullable().optional(), + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + model: z.string().nullable(), + filePath: z.string(), + codexHome: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + modifiedAt: z.string(), + messageCount: z.number(), + totalTokens: z.number(), + previewMessages: z.array(aiVaultSessionPreviewMessageSchema), + resumeCommand: z.string() + }) + ), + issues: z.array( + z.object({ + executionHostId: executionHostIdSchema.optional(), + agent: z.enum(AI_VAULT_AGENTS), + path: z.string(), + message: z.string() + }) + ), + scannedAt: z.string() +}) + +export function getSavedRuntimeAiVaultHostInfos( + userDataPath: string +): readonly RuntimeAiVaultHostInfo[] { + return listEnvironments(userDataPath).map((environment) => ({ + environmentId: environment.id, + executionHostId: toRuntimeExecutionHostId(environment.id) + })) +} + +export async function scanRuntimeAiVaultSessions( + userDataPath: string, + environmentId: string, + args: AiVaultListArgs, + options: RuntimeAiVaultScanOptions = {} +): Promise { + const executionHostId = toRuntimeExecutionHostId(environmentId) + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'aiVault.listSessions', + { + limit: args.limit, + force: args.force, + scopePaths: args.scopePaths, + executionHostId + }, + options.timeoutMs + ) + if (response.ok === true) { + const parsed = aiVaultListResultSchema.safeParse(response.result) + if (parsed.success) { + return withRuntimeExecutionHost(parsed.data, executionHostId) + } + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: `Invalid aiVault.listSessions response: ${ + parsed.error.issues[0]?.message ?? 'unexpected result shape' + }` + }) + } + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: response.error.message + }) +} + +function withRuntimeExecutionHost( + result: AiVaultListResult, + executionHostId: `runtime:${string}` +): AiVaultListResult { + return { + sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)), + issues: result.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: result.scannedAt + } +} + +function retagRuntimeSession( + session: AiVaultSession, + executionHostId: `runtime:${string}` +): AiVaultSession { + // The paired server is the transport, but the parent owns which concrete + // runtime host was scanned; never trust returned host ids across the boundary. + return { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } +} + +function runtimeScanIssueResult(args: { + executionHostId: `runtime:${string}` + environmentId: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId: args.executionHostId, + agent: 'codex', + path: args.environmentId, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts new file mode 100644 index 00000000000..ea21dc590f6 --- /dev/null +++ b/src/main/ai-vault/session-list-results.ts @@ -0,0 +1,48 @@ +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { sessionSortTime } from './session-scanner-accumulator' + +export function aiVaultScanIssueResult(args: { + executionHostId?: ExecutionHostId + path: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), + agent: 'codex', + path: args.path, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} + +export function mergeAiVaultListResults( + results: readonly AiVaultListResult[], + rawLimit: number | undefined +): AiVaultListResult { + const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { + byId.set(session.id, session) + } + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() + } +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 9cc8060eee2..0fae2259f12 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -6,6 +6,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), + scanRuntimeAiVaultSessions: vi.fn(), getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), @@ -41,7 +42,7 @@ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos })) -const { _internals } = await import('./ai-vault') +const { _internals, registerAiVaultHandlers } = await import('./ai-vault') const provider = {} as IFilesystemProvider @@ -52,6 +53,9 @@ beforeEach(() => { mocks.scanRemoteAiVaultSessions.mockResolvedValue( result([session('ssh:dev-box', 'remote-session')]) ) + mocks.scanRuntimeAiVaultSessions.mockResolvedValue( + result([session('runtime:remote-server', 'runtime-session')]) + ) mocks.getSshFilesystemProvider.mockReturnValue(provider) mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) @@ -96,6 +100,77 @@ describe('listAiVaultSessions host routing', () => { expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) + it('merges paired runtime servers for all hosts', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [ + { + environmentId: 'remote-server', + executionHostId: 'runtime:remote-server' + } + ], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( + 'remote-server', + { + executionHostScope: 'runtime:remote-server' + }, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([ + 'runtime:remote-server', + 'ssh:dev-box', + 'local' + ]) + }) + + it('keeps local and SSH results when runtime host discovery fails', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => { + throw new Error('runtime store is invalid') + }, + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'codex', + path: 'runtime environments', + message: 'runtime store is invalid' + }) + ]) + }) + + it('keeps direct runtime host scans on the normal runtime timeout', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + await _internals.listAiVaultSessions({ + executionHostScope: 'runtime:remote-server', + force: true + }) + + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( + 'remote-server', + { + executionHostScope: 'runtime:remote-server', + force: true + }, + {} + ) + }) + it('returns a scan issue for a disconnected SSH target', async () => { mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) mocks.getSshFilesystemProvider.mockReturnValue(undefined) @@ -153,7 +228,11 @@ function session( codexHome: null, createdAt: null, updatedAt: - sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z', + sessionId === 'runtime-session' + ? '2026-07-04T03:00:00.000Z' + : sessionId === 'remote-session' + ? '2026-07-04T02:00:00.000Z' + : '2026-07-04T01:00:00.000Z', modifiedAt: '2026-07-04T00:00:00.000Z', messageCount: 1, totalTokens: 0, diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 4551379afde..94a8de0c475 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -2,17 +2,14 @@ import { app, ipcMain } from 'electron' import { join } from 'node:path' import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { scanAiVaultSessions } from '../ai-vault/session-scanner' -import { sessionSortTime } from '../ai-vault/session-scanner-accumulator' +import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import type { - AiVaultListArgs, - AiVaultListResult, - AiVaultScanIssue -} from '../../shared/ai-vault-types' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope, parseExecutionHostId, + toRuntimeExecutionHostId, toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' @@ -23,9 +20,20 @@ import { import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' const AI_VAULT_CACHE_TTL_MS = 15_000 +const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 type AiVaultHandlerOptions = { getAdditionalCodexHomePaths?: () => readonly string[] + getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] + scanRuntimeAiVaultSessions?: ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions + ) => Promise +} + +type RuntimeAiVaultScanOptions = { + timeoutMs?: number } type CachedAiVaultList = { @@ -34,6 +42,11 @@ type CachedAiVaultList = { expiresAt: number } +type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + let cachedList: CachedAiVaultList | null = null let inflightList: Promise | null = null let inflightKey: string | null = null @@ -89,12 +102,20 @@ async function scanAiVaultSessionsByHostScope( } if (executionHostScope === 'all') { + const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() + const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] return mergeAiVaultListResults( await Promise.all([ scanLocalAiVaultSessions(args), ...getActiveSshAiVaultHostInfos().map((hostInfo) => scanSshAiVaultSessions(hostInfo.targetId, args) - ) + ), + ...runtimeHosts.hostInfos.map((hostInfo) => + scanRuntimeAiVaultSessions(hostInfo, args, { + timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + }) + ), + ...runtimeResults ]), args?.limit ) @@ -104,19 +125,88 @@ async function scanAiVaultSessionsByHostScope( if (parsed?.kind === 'ssh') { return scanSshAiVaultSessions(parsed.targetId, args) } - - return { - sessions: [], - issues: [ + if (parsed?.kind === 'runtime') { + return scanRuntimeAiVaultSessions( { - executionHostId: executionHostScope, - agent: 'codex', - path: executionHostScope, - message: 'Agent Session History is not available for this execution host.' - } - ], - scannedAt: new Date().toISOString() + environmentId: parsed.environmentId, + executionHostId: toRuntimeExecutionHostId(parsed.environmentId) + }, + args + ) } + + return aiVaultScanIssueResult({ + executionHostId: executionHostScope, + path: executionHostScope, + message: 'Agent Session History is not available for this execution host.' + }) +} + +function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] { + return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [] +} + +function getActiveRuntimeAiVaultHostInfosResult(): { + hostInfos: readonly RuntimeAiVaultHostInfo[] + issue?: AiVaultListResult +} { + try { + return { hostInfos: getActiveRuntimeAiVaultHostInfos() } + } catch (error) { + return { + hostInfos: [], + issue: runtimeHostDiscoveryIssueResult( + error instanceof Error ? error.message : 'Runtime hosts are unavailable.' + ) + } + } +} + +async function scanRuntimeAiVaultSessions( + hostInfo: RuntimeAiVaultHostInfo, + args?: AiVaultListArgs, + options: RuntimeAiVaultScanOptions = {} +): Promise { + const scanner = handlerOptions.scanRuntimeAiVaultSessions + if (!scanner) { + return runtimeScanIssueResult( + hostInfo, + 'Agent Session History is not available for this execution host.' + ) + } + const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (args?.limit !== undefined) { + scanArgs.limit = args.limit + } + if (args?.force !== undefined) { + scanArgs.force = args.force + } + if (args?.scopePaths !== undefined) { + scanArgs.scopePaths = args.scopePaths + } + try { + return await scanner(hostInfo.environmentId, scanArgs, options) + } catch (error) { + return runtimeScanIssueResult( + hostInfo, + error instanceof Error ? error.message : 'Remote Orca server is unavailable.' + ) + } +} + +function runtimeScanIssueResult( + hostInfo: RuntimeAiVaultHostInfo, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ + executionHostId: hostInfo.executionHostId, + path: hostInfo.environmentId, + message + }) +} + +function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { + return aiVaultScanIssueResult({ path: 'runtime environments', message }) } async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { @@ -161,40 +251,11 @@ function sshScanIssueResult(args: { targetId: string message: string }): AiVaultListResult { - return { - sessions: [], - issues: [ - { - executionHostId: args.executionHostId, - agent: 'codex', - path: args.targetId, - message: args.message - } - ], - scannedAt: new Date().toISOString() - } -} - -function mergeAiVaultListResults( - results: readonly AiVaultListResult[], - rawLimit: number | undefined -): AiVaultListResult { - const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 - const byId = new Map() - const issues: AiVaultScanIssue[] = [] - for (const result of results) { - for (const session of result.sessions) { - byId.set(session.id, session) - } - issues.push(...result.issues) - } - return { - sessions: [...byId.values()] - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit), - issues, - scannedAt: new Date().toISOString() - } + return aiVaultScanIssueResult({ + executionHostId: args.executionHostId, + path: args.targetId, + message: args.message + }) } export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 0b7c4e07146..6a73db56da6 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { + getPathMock, + listEnvironmentsMock, + callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, registerClaudeUsageHandlersMock, @@ -57,6 +60,9 @@ const { registerEmulatorFrameStreamHandlersMock, registerEmulatorVideoStreamHandlersMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/test/user-data'), + listEnvironmentsMock: vi.fn(() => []), + callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), @@ -114,6 +120,20 @@ const { registerEmulatorVideoStreamHandlersMock: vi.fn() })) +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: listEnvironmentsMock +})) + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: callRuntimeEnvironmentMock +})) + vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) @@ -326,6 +346,11 @@ import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { beforeEach(() => { + getPathMock.mockReset() + getPathMock.mockReturnValue('/test/user-data') + listEnvironmentsMock.mockReset() + listEnvironmentsMock.mockReturnValue([]) + callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() @@ -382,7 +407,7 @@ describe('registerCoreHandlers', () => { registerEmulatorVideoStreamHandlersMock.mockReset() }) - it('passes the store through to handler registrars that need it', () => { + it('passes the store through to handler registrars that need it', async () => { const store = { marker: 'store' } const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null } const stats = { marker: 'stats' } @@ -415,6 +440,14 @@ describe('registerCoreHandlers', () => { { getAdditionalAiVaultCodexHomePaths, onBeforeRelaunch } ) + const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0] + expect(aiVaultOptions).toBeDefined() + + callRuntimeEnvironmentMock.mockResolvedValueOnce({ + ok: true, + result: { sessions: 'bad-shape' } + }) + expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) @@ -451,9 +484,14 @@ describe('registerCoreHandlers', () => { expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store) - expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ - getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths - }) + expect(registerAiVaultHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ + getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, + getActiveRuntimeAiVaultHostInfos: expect.any(Function), + scanRuntimeAiVaultSessions: expect.any(Function) + }) + ) + expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([]) expect(registerNativeChatHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() @@ -466,6 +504,40 @@ describe('registerCoreHandlers', () => { expect(registerBrowserHandlersMock).toHaveBeenCalled() expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled() expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store) + + await expect( + aiVaultOptions.scanRuntimeAiVaultSessions( + 'env-123', + { + limit: 10, + scopePaths: ['/workspace'] + }, + { timeoutMs: 3000 } + ) + ).resolves.toEqual({ + sessions: [], + issues: [ + expect.objectContaining({ + executionHostId: 'runtime:env-123', + agent: 'codex', + path: 'env-123', + message: expect.stringContaining('Invalid aiVault.listSessions response') + }) + ], + scannedAt: expect.any(String) + }) + expect(callRuntimeEnvironmentMock).toHaveBeenCalledWith( + '/test/user-data', + 'env-123', + 'aiVault.listSessions', + { + limit: 10, + force: undefined, + scopePaths: ['/workspace'], + executionHostId: 'runtime:env-123' + }, + 3000 + ) }) it('only registers IPC handlers once but always updates web contents id', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 50dbd0a8ff4..f651cd002d7 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -1,3 +1,4 @@ +import { app } from 'electron' import { registerAppHandlers } from './app' import { registerCliHandlers } from './cli' import { registerPreflightHandlers } from './preflight' @@ -69,6 +70,10 @@ import type { AutomationService } from '../automations/service' import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import type { KeybindingService } from '../keybindings/keybinding-service' +import { + getSavedRuntimeAiVaultHostInfos, + scanRuntimeAiVaultSessions +} from '../ai-vault/runtime-session-scanner' let registered = false @@ -171,7 +176,11 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store) registerAiVaultHandlers({ - getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths + getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, + getActiveRuntimeAiVaultHostInfos: () => + getSavedRuntimeAiVaultHostInfos(app.getPath('userData')), + scanRuntimeAiVaultSessions: async (environmentId, args, options) => + scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts new file mode 100644 index 00000000000..c7cc56a6490 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_METHODS } from './ai-vault' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn() +})) + +vi.mock('../../../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.scanAiVaultSessions.mockResolvedValue({ + sessions: [], + issues: [], + scannedAt: '2026-07-04T00:00:00.000Z' + }) +}) + +describe('aiVault.listSessions runtime RPC', () => { + it('scans server-local sessions under the caller supplied runtime host id', async () => { + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + expect(method).toBeDefined() + + const params = method!.params!.parse({ + limit: 25, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:remote-server' + }) + + await method!.handler(params, {} as never) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({ + limit: 25, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:remote-server' + }) + }) + + it('rejects non-runtime execution host ids before dispatch', () => { + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + expect(method).toBeDefined() + + expect(() => + method!.params!.parse({ + executionHostId: 'not-a-runtime-host' + }) + ).toThrow('Invalid runtime execution host id') + expect(() => + method!.params!.parse({ + executionHostId: 'local' + }) + ).toThrow('Invalid runtime execution host id') + expect(() => + method!.params!.parse({ + executionHostId: 'ssh:dev-box' + }) + ).toThrow('Invalid runtime execution host id') + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts new file mode 100644 index 00000000000..49591ea1e40 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { scanAiVaultSessions } from '../../../ai-vault/session-scanner' +import { defineMethod, type RpcMethod } from '../core' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { + const parsed = parseExecutionHostId(value) + if (parsed?.kind === 'runtime') { + return parsed.id + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid runtime execution host id' + }) + return z.NEVER +}) + +const listSessionsParamsSchema = z.object({ + limit: z.number().int().positive().optional(), + force: z.boolean().optional(), + scopePaths: z.array(z.string()).optional(), + executionHostId: executionHostIdSchema +}) + +export const AI_VAULT_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'aiVault.listSessions', + params: listSessionsParamsSchema, + handler: async (params) => + scanAiVaultSessions({ + limit: params.limit, + scopePaths: params.scopePaths, + executionHostId: params.executionHostId + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 8018375a8a0..bc0a73985f9 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,6 @@ import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' +import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' @@ -39,6 +40,7 @@ import { EMULATOR_METHODS } from './emulator' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AI_VAULT_METHODS, ...AUTOMATION_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 8442e73f54e..4a6bccec9db 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -28,7 +28,6 @@ import { import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' -import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import { AI_VAULT_AGENTS, type AiVaultAgent, @@ -41,7 +40,11 @@ import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' -import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' +import { + buildAiVaultHostScopeOptions, + buildRuntimeAiVaultHostScopeOptions, + useAiVaultExecutionHostScope +} from './ai-vault-host-scope' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -59,6 +62,7 @@ export default function AiVaultPanel(): React.JSX.Element { })) ) const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const agentCmdOverrides = settings?.agentCmdOverrides const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } = useAiVaultOriginalPaneActions() @@ -72,17 +76,28 @@ export default function AiVaultPanel(): React.JSX.Element { const userChangedScopeRef = useRef(false) const preferredScopeRef = useRef(DEFAULT_AI_VAULT_SCOPE) - const isRuntimeWorktree = useMemo( - () => - getAiVaultResumeWorkspaceTargetStatus(resumeTargetState, activeWorktreeId ?? null) === - 'runtime', - [activeWorktreeId, resumeTargetState] + const runtimeHostOptions = useMemo( + () => buildRuntimeAiVaultHostScopeOptions(runtimeEnvironments), + [runtimeEnvironments] ) - const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } = + const availableExecutionHostScopes = useMemo( + () => runtimeHostOptions.map((option) => option.id), + [runtimeHostOptions] + ) + const { executionHostScope, activeExecutionHostScope, onExecutionHostScopeChange } = useAiVaultExecutionHostScope({ activeWorktreeId: activeWorktreeId ?? null, - resumeTargetState + resumeTargetState, + availableExecutionHostScopes }) + const hostScopeOptions = useMemo( + () => + buildAiVaultHostScopeOptions({ + activeExecutionHostScope, + runtimeHostOptions + }), + [activeExecutionHostScope, runtimeHostOptions] + ) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( @@ -306,7 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element { activeProjectKey={activeProjectKey} scope={scope} executionHostScope={executionHostScope} - activeSshExecutionHostScope={activeSshExecutionHostScope} + hostScopeOptions={hostScopeOptions} agents={agents} sort={sort} group={group} @@ -323,15 +338,6 @@ export default function AiVaultPanel(): React.JSX.Element { onRefresh={() => void refresh({ force: true })} /> - {isRuntimeWorktree ? ( -
- {translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeBrowseLocalHistory', - 'Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.' - )} -
- ) : null} - {error ? (
{error} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 2b3cc2802b0..b64308b943e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -32,14 +32,10 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' -import { - ALL_EXECUTION_HOSTS_SCOPE, - getExecutionHostLabel, - LOCAL_EXECUTION_HOST_ID, - type ExecutionHostScope -} from '../../../../shared/execution-host' +import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' +import type { AiVaultHostScopeOption } from './ai-vault-host-scope' const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0' @@ -184,19 +180,15 @@ export function VaultScopeSwitch({ export function VaultHostScopeMenu({ executionHostScope, - activeSshExecutionHostScope, + hostOptions, onExecutionHostScopeChange }: { executionHostScope: ExecutionHostScope - activeSshExecutionHostScope: ExecutionHostScope | null + hostOptions: readonly AiVaultHostScopeOption[] onExecutionHostScopeChange: (scope: ExecutionHostScope) => void }): React.JSX.Element { - const hostOptions = [ - LOCAL_EXECUTION_HOST_ID, - ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), - ALL_EXECUTION_HOSTS_SCOPE - ] as const - const label = getExecutionHostLabel(executionHostScope) + const selectedOption = hostOptions.find((option) => option.id === executionHostScope) + const label = selectedOption?.label ?? getExecutionHostLabel(executionHostScope) return ( @@ -224,9 +216,9 @@ export function VaultHostScopeMenu({ value={executionHostScope} onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)} > - {hostOptions.map((scope) => ( - - {getExecutionHostLabel(scope)} + {hostOptions.map((option) => ( + + {option.label} ))} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index b463e188a7e..b3ec2ba36e3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -9,6 +9,7 @@ import type { } from '../../../../shared/ai-vault-types' import type { ExecutionHostScope } from '../../../../shared/execution-host' import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { AiVaultHostScopeOption } from './ai-vault-host-scope' type AiVaultPanelHeaderProps = { query: string @@ -20,7 +21,7 @@ type AiVaultPanelHeaderProps = { activeProjectKey: string | null scope: AiVaultScope executionHostScope: ExecutionHostScope - activeSshExecutionHostScope: ExecutionHostScope | null + hostScopeOptions: readonly AiVaultHostScopeOption[] agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -47,7 +48,7 @@ export function AiVaultPanelHeader({ activeProjectKey, scope, executionHostScope, - activeSshExecutionHostScope, + hostScopeOptions, agents, sort, group, @@ -108,7 +109,7 @@ export function AiVaultPanelHeader({
@@ -15,6 +20,7 @@ let latest: HostScopeResult | null = null function HookProbe(props: { activeWorktreeId: string | null resumeTargetState: AiVaultSessionResumeTargetState + availableExecutionHostScopes?: readonly ExecutionHostScope[] }): null { latest = useAiVaultExecutionHostScope(props) return null @@ -23,6 +29,7 @@ function HookProbe(props: { async function renderHook(props: { activeWorktreeId: string | null resumeTargetState: AiVaultSessionResumeTargetState + availableExecutionHostScopes?: readonly ExecutionHostScope[] }): Promise { if (!root) { const container = document.createElement('div') @@ -83,7 +90,7 @@ describe('useAiVaultExecutionHostScope', () => { }) expect(latest?.executionHostScope).toBe('ssh:dev-box') - expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box') + expect(latest?.activeExecutionHostScope).toBe('ssh:dev-box') }) it('defaults local worktrees to local history', async () => { @@ -96,7 +103,22 @@ describe('useAiVaultExecutionHostScope', () => { }) expect(latest?.executionHostScope).toBe('local') - expect(latest?.activeSshExecutionHostScope).toBeNull() + expect(latest?.activeExecutionHostScope).toBeNull() + }) + + it('defaults runtime worktrees to their runtime execution host', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/runtime/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/runtime/repo', + repoId: 'repo-1', + executionHostId: 'runtime:remote-server' + }), + availableExecutionHostScopes: ['runtime:remote-server'] as const + }) + + expect(latest?.executionHostScope).toBe('runtime:remote-server') + expect(latest?.activeExecutionHostScope).toBe('runtime:remote-server') }) it('preserves manual host scope changes across unrelated rerenders', async () => { @@ -117,4 +139,56 @@ describe('useAiVaultExecutionHostScope', () => { expect(latest?.executionHostScope).toBe('all') }) + + it('preserves manual runtime host scope choices while they are still available', async () => { + const props = { + activeWorktreeId: 'repo-1::/local/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/local/repo', + repoId: 'repo-1' + }), + availableExecutionHostScopes: ['runtime:remote-server'] as const + } + await renderHook(props) + + await act(async () => { + latest?.onExecutionHostScopeChange('runtime:remote-server') + }) + await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } }) + + expect(latest?.executionHostScope).toBe('runtime:remote-server') + }) +}) + +describe('buildAiVaultHostScopeOptions', () => { + it('adds saved runtime hosts between active SSH and all hosts', () => { + const runtimeHostOptions = buildRuntimeAiVaultHostScopeOptions([ + { id: 'remote-server', name: 'VPS Orca Server' } + ]) + + expect( + buildAiVaultHostScopeOptions({ + activeExecutionHostScope: 'ssh:dev-box', + runtimeHostOptions + }) + ).toEqual([ + { id: 'local', label: expect.any(String) }, + { id: 'ssh:dev-box', label: 'dev-box' }, + { id: 'runtime:remote-server', label: 'VPS Orca Server' }, + { id: 'all', label: 'All hosts' } + ]) + }) + + it('keeps a runtime active host visible even before the saved environment list hydrates', () => { + expect( + buildAiVaultHostScopeOptions({ + activeExecutionHostScope: 'runtime:remote-server', + runtimeHostOptions: [] + }) + ).toEqual([ + { id: 'local', label: expect.any(String) }, + { id: 'runtime:remote-server', label: 'remote-server' }, + { id: 'all', label: 'All hosts' } + ]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts index 8993e8a00e6..0acb4f54644 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' import { ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' +export type AiVaultHostScopeOption = { + id: ExecutionHostScope + label: string +} + export function useAiVaultExecutionHostScope(args: { activeWorktreeId: string | null resumeTargetState: AiVaultSessionResumeTargetState + availableExecutionHostScopes?: readonly ExecutionHostScope[] }): { executionHostScope: ExecutionHostScope - activeSshExecutionHostScope: ExecutionHostScope | null + activeExecutionHostScope: ExecutionHostId | null onExecutionHostScopeChange: (scope: ExecutionHostScope) => void } { const userChangedHostScopeRef = useRef(false) @@ -22,10 +32,12 @@ export function useAiVaultExecutionHostScope(args: { [args.activeWorktreeId, args.resumeTargetState] ) const activeExecutionHost = parseExecutionHostId(activeExecutionHostId) + const activeExecutionHostScope: ExecutionHostId | null = + activeExecutionHost?.kind === 'ssh' || activeExecutionHost?.kind === 'runtime' + ? activeExecutionHost.id + : null const defaultExecutionHostScope: ExecutionHostScope = - activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID - const activeSshExecutionHostScope: ExecutionHostScope | null = - activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null + activeExecutionHostScope ?? LOCAL_EXECUTION_HOST_ID const [executionHostScope, setExecutionHostScope] = useState(defaultExecutionHostScope) @@ -36,7 +48,8 @@ export function useAiVaultExecutionHostScope(args: { const allowedScopes = new Set([ LOCAL_EXECUTION_HOST_ID, ALL_EXECUTION_HOSTS_SCOPE, - ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []) + ...(activeExecutionHostScope ? [activeExecutionHostScope] : []), + ...(args.availableExecutionHostScopes ?? []) ]) if (!allowedScopes.has(executionHostScope)) { setExecutionHostScope(defaultExecutionHostScope) @@ -46,7 +59,12 @@ export function useAiVaultExecutionHostScope(args: { if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) { setExecutionHostScope(defaultExecutionHostScope) } - }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope]) + }, [ + activeExecutionHostScope, + args.availableExecutionHostScopes, + defaultExecutionHostScope, + executionHostScope + ]) const handleExecutionHostScopeChange = useCallback( (nextScope: ExecutionHostScope) => { @@ -58,7 +76,49 @@ export function useAiVaultExecutionHostScope(args: { return { executionHostScope, - activeSshExecutionHostScope, + activeExecutionHostScope, onExecutionHostScopeChange: handleExecutionHostScopeChange } } + +export function buildRuntimeAiVaultHostScopeOptions( + runtimeEnvironments: readonly Pick[] +): AiVaultHostScopeOption[] { + return runtimeEnvironments.map((environment) => { + const id = toRuntimeExecutionHostId(environment.id) + const label = environment.name.trim() || getExecutionHostLabel(id) + return { id, label } + }) +} + +export function buildAiVaultHostScopeOptions(args: { + activeExecutionHostScope: ExecutionHostId | null + runtimeHostOptions: readonly AiVaultHostScopeOption[] +}): AiVaultHostScopeOption[] { + const options: AiVaultHostScopeOption[] = [] + const seen = new Set() + const add = (option: AiVaultHostScopeOption): void => { + if (seen.has(option.id)) { + return + } + seen.add(option.id) + options.push(option) + } + const activeHost = args.activeExecutionHostScope + ? parseExecutionHostId(args.activeExecutionHostScope) + : null + + add({ id: LOCAL_EXECUTION_HOST_ID, label: getExecutionHostLabel(LOCAL_EXECUTION_HOST_ID) }) + if (activeHost?.kind === 'ssh') { + add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) }) + } + for (const option of args.runtimeHostOptions) { + add(option) + } + if (activeHost?.kind === 'runtime') { + add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) }) + } + add({ id: ALL_EXECUTION_HOSTS_SCOPE, label: getExecutionHostLabel(ALL_EXECUTION_HOSTS_SCOPE) }) + + return options +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index c55acb1e939..78daad963d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -100,21 +100,40 @@ export function useAiVaultSessionLaunchActions({ return } - launchAiVaultSessionInNewTab({ + const launchResult = launchAiVaultSessionInNewTab({ agent: session.agent, worktreeId: targetId.worktreeId, ...buildResumeStartup(session, targetId.worktreeId) }) + const showQueuedToast = (): void => { + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', + '{{value0}} session queued', + { value0: agentLabel(session.agent) } + ) + ) + } + if (launchResult.tabId === null) { + void launchResult.runtimeLaunch.then((created) => { + if (!created) { + toast.error( + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agentLabel(session.agent) } + ) + ) + return + } + showQueuedToast() + }) + return + } if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { activateAiVaultResumeWorkspace(targetId.worktreeId) } - toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', - '{{value0}} session queued', - { value0: agentLabel(session.agent) } - ) - ) + showQueuedToast() }, [activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState] ) @@ -167,15 +186,9 @@ export function resolveAiVaultSessionLaunchTarget(args: { function aiVaultResumeUnsupportedMessage( targetStatus: ReturnType ): string { - if (targetStatus === 'runtime') { - return translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - } // Why: local and SSH targets can both be valid generally; this branch means // the session's recorded host does not match the selected workspace. - if (targetStatus === 'ssh' || targetStatus === 'local') { + if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') { return translate( 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', 'This session belongs to a different host. Open a workspace on the same host to resume it.' @@ -183,7 +196,7 @@ function aiVaultResumeUnsupportedMessage( } return translate( 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' + 'Open a workspace before resuming a session.' ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 5e8bc3f4242..381b1d49c73 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -194,10 +194,33 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) - it('blocks runtime-owned targets even when they have no SSH connection', () => { + it('allows runtime-owned targets for matching runtime sessions', () => { expect( resolveAiVaultSessionResumeState({ sessionFilePath: HOST_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-1', + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: null, + worktrees: [makeWorktree()], + repos: [ + makeRepo({ + connectionId: null, + executionHostId: 'runtime:env-1' + }) + ] + }) + ).toEqual({ + blocked: false, + worktreeId: 'repo-1::/repo/orca', + usesSessionWorktree: true + }) + }) + + it('blocks runtime-owned targets for sessions from another host', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + sessionExecutionHostId: 'local', worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -215,19 +238,20 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) - it('blocks runtime-stamped worktrees even when the repo owner is local', () => { + it('allows runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ sessionFilePath: HOST_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-1', worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], repos: [makeRepo({ connectionId: null, executionHostId: 'local' })] }) ).toEqual({ - blocked: true, - worktreeId: null, - usesSessionWorktree: false + blocked: false, + worktreeId: 'repo-1::/repo/orca', + usesSessionWorktree: true }) }) @@ -319,10 +343,11 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) - it('blocks an active runtime folder workspace', () => { + it('allows an active runtime folder workspace for matching runtime sessions', () => { expect( resolveAiVaultSessionResumeState({ sessionFilePath: HOST_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-1', worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -330,8 +355,8 @@ describe('resolveAiVaultSessionResumeState', () => { targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) ).toEqual({ - blocked: true, - worktreeId: null, + blocked: false, + worktreeId: folderWorkspaceKey('folder-1'), usesSessionWorktree: false }) }) @@ -461,10 +486,11 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) - it('disables the active folder workspace action when it is runtime-owned', () => { + it('enables the active folder workspace action when it is a matching runtime host', () => { expect( resolveAiVaultSessionResumeActions({ sessionFilePath: HOST_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-1', worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -473,7 +499,7 @@ describe('resolveAiVaultSessionResumeActions', () => { }) ).toEqual({ worktree: { worktreeId: null, disabled: true }, - newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: true } + newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: false } }) }) }) @@ -505,10 +531,25 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) - it('blocks direct resume into a runtime folder workspace', () => { + it('allows direct resume into a matching runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ sessionFilePath: WSL_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-1', + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) + }) + ).toEqual({ + status: 'ready', + worktreeId: folderWorkspaceKey('folder-1') + }) + }) + + it('blocks direct resume into a mismatched runtime folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, + sessionExecutionHostId: 'runtime:env-2', activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index af09c852cab..2ae7765022a 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -173,20 +173,11 @@ export default function AiVaultSessionDropLayer({ const state = useAppStore.getState() const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId) - if (targetStatus === 'runtime') { - toast.error( - translate( - 'auto.components.tab.group.AiVaultSessionDropLayer.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - ) - return true - } if (targetStatus === 'unknown') { toast.error( translate( 'auto.components.tab.group.AiVaultSessionDropLayer.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' + 'Open a workspace before resuming a session.' ) ) return true @@ -208,7 +199,7 @@ export default function AiVaultSessionDropLayer({ return true } - launchAiVaultSessionInNewTab({ + const launchResult = launchAiVaultSessionInNewTab({ agent: payload.agent, worktreeId, command: payload.command, @@ -217,12 +208,31 @@ export default function AiVaultSessionDropLayer({ targetGroupId: dropTarget.groupId, splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone }) - toast.success( - translate( - 'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued', - 'Session queued' + const showQueuedToast = (): void => { + toast.success( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued', + 'Session queued' + ) ) - ) + } + if (launchResult.tabId === null) { + void launchResult.runtimeLaunch.then((created) => { + if (!created) { + toast.error( + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: payload.agent } + ) + ) + return + } + showQueuedToast() + }) + return true + } + showQueuedToast() return true }, [clearDragState, target, updateTarget, worktreeId] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 01637ff30de..d0dc2e8dea5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -137,6 +137,9 @@ "e3bcd082ac": "Connect to Orca", "mobileScopeRejected": "This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link." }, + "webPreloadApi": { + "aiVaultUnavailableForHost": "Agent Session History is not available for this execution host." + }, "web": { "preload": { "api": { @@ -2588,8 +2591,7 @@ "localWorkspacesOnly": "Resume from history is only available in local workspaces.", "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, @@ -9648,9 +9650,7 @@ "logPath": "Log path", "originalPaneUnavailable": "Original pane is no longer available.", "worktreeUnavailable": "Worktree is no longer available.", - "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e9af23a0ceb..8923d561dfb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -137,6 +137,9 @@ "e3bcd082ac": "Conectar con Orca", "mobileScopeRejected": "Este código QR solo da acceso limitado para móvil. Para usar la app web completa, abre el enlace para navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace." }, + "webPreloadApi": { + "aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución." + }, "web": { "preload": { "api": { @@ -2588,8 +2591,7 @@ "localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.", "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", "sessionQueued": "Sesión en cola", - "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.", - "openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.", + "openSupportedWorkspace": "Abre un espacio de trabajo antes de reanudar una sesión.", "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.", "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host." }, @@ -9648,9 +9650,7 @@ "sessionsShownCompact": "{{value0}} mostradas", "originalPaneUnavailable": "El panel original ya no está disponible.", "worktreeUnavailable": "El worktree ya no está disponible.", - "runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.", - "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.", - "openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.", + "openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.", "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2aa5e241110..e5bdf33842f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -137,6 +137,9 @@ "e3bcd082ac": "Orca に接続", "mobileScopeRejected": "この QR コードは制限付き(モバイル)アクセスを許可します。完全な Web アプリを使うには、設定 → リモート Orca サーバー → この Orca サーバーを共有する → 新規リンク からブラウザー用アクセスリンクを開いてください。" }, + "webPreloadApi": { + "aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。" + }, "web": { "preload": { "api": { @@ -2588,8 +2591,7 @@ "localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。", "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, @@ -9648,9 +9650,7 @@ "sessionsShownCompact": "{{value0}} 件表示", "originalPaneUnavailable": "元のペインは利用できなくなりました。", "worktreeUnavailable": "ワークツリーは利用できなくなりました。", - "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dcbaadc3e27..db8a2aea5d1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -137,6 +137,9 @@ "e3bcd082ac": "Orca에 연결", "mobileScopeRejected": "이 QR 코드는 제한된(모바일) 액세스 권한만 제공합니다. 전체 웹 앱을 사용하려면 설정 → 원격 Orca 서버 → 이 Orca 서버 공유 → 새 링크에서 브라우저 액세스 링크를 여세요." }, + "webPreloadApi": { + "aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다." + }, "web": { "preload": { "api": { @@ -2588,8 +2591,7 @@ "localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.", "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, @@ -9648,9 +9650,7 @@ "sessionsShownCompact": "{{value0}} 표시됨", "originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.", "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", - "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 968e88e0ec1..d6cce4de202 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -137,6 +137,9 @@ "e3bcd082ac": "连接到 Orca", "mobileScopeRejected": "此二维码仅授予受限(移动端)访问权限。若要使用完整 Web 应用,请从设置 → 远程 Orca 服务器 → 分享此 Orca 服务器 → 新链接打开浏览器访问链接。" }, + "webPreloadApi": { + "aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。" + }, "web": { "preload": { "api": { @@ -2588,8 +2591,7 @@ "localWorkspacesOnly": "从历史恢复仅支持本地工作区。", "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队", - "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", - "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", + "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。" }, @@ -9648,9 +9650,7 @@ "sessionsShownCompact": "已显示 {{value0}} 个", "originalPaneUnavailable": "原始面板不再可用。", "worktreeUnavailable": "工作树不再可用。", - "runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。", - "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", - "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", + "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" }, diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 207bc525a59..f7903f13000 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -114,10 +114,33 @@ describe('ai vault session storage compatibility', () => { ).toBe(true) }) - it('never allows runtime or unknown targets', () => { + it('allows runtime sessions only on the matching runtime target', () => { expect( - canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'runtime:env-1', + targetStatus: 'runtime', + targetExecutionHostId: 'runtime:env-1' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'runtime:env-1', + targetStatus: 'runtime', + targetExecutionHostId: 'runtime:env-2' + }) ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: wslSessionFile, + targetStatus: 'runtime', + targetExecutionHostId: 'runtime:env-1' + }) + ).toBe(false) + }) + + it('never allows unknown targets', () => { expect( canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' }) ).toBe(false) @@ -149,8 +172,11 @@ describe('ai vault resume target ownership', () => { true ) expect( - isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }) + isSupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }) ).toBe(true) + expect( + isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }) + ).toBe(false) }) it('resolves runtime-owned worktree targets through their repo owner', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 823840ec85b..f52e26c62e8 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -35,7 +35,7 @@ export function isSupportedAiVaultResumeRepo( } export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTargetStatus): boolean { - return status === 'local' || status === 'ssh' + return status === 'local' || status === 'ssh' || status === 'runtime' } export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean { @@ -48,11 +48,20 @@ export function canResumeAiVaultSessionOnTarget(args: { targetStatus: AiVaultResumeTargetStatus targetExecutionHostId?: ExecutionHostId | null }): boolean { + const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) + if (args.targetStatus === 'runtime') { + // Runtime session stores live on one paired server; only queue resumes back + // onto that exact server host. + return Boolean( + sessionExecutionHostId && + targetExecutionHostId && + sessionExecutionHostId === targetExecutionHostId + ) + } if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { return false } - const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) - const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) if (sessionExecutionHostId) { if (targetExecutionHostId) { if (sessionExecutionHostId === targetExecutionHostId) { @@ -84,7 +93,8 @@ export function canResumeAiVaultSessionOnTarget(args: { export function isUnsupportedAiVaultResumeRepo( repo: AiVaultResumeRepoOwner | null | undefined ): boolean { - return getAiVaultResumeRepoTargetStatus(repo) === 'runtime' + const status = getAiVaultResumeRepoTargetStatus(repo) + return status !== 'unknown' && !isSupportedAiVaultResumeTargetStatus(status) } export function getAiVaultResumeWorktreeTargetStatus(args: { diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index e6a1c083a76..ef0425b58e6 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -5,6 +5,11 @@ const mockCreateEmptySplitGroup = vi.fn() const mockQueueTabStartupCommand = vi.fn() const mockSetActiveTabType = vi.fn() const mockSetTabBarOrder = vi.fn() +const runtimeMocks = vi.hoisted(() => ({ + createWebRuntimeSessionTerminal: vi.fn(), + getRuntimeEnvironmentIdForWorktree: vi.fn<() => string | null>(() => null), + isWebRuntimeSessionActive: vi.fn(() => false) +})) const mockState = { createTab: mockCreateTab, @@ -37,11 +42,23 @@ vi.mock('@/lib/telemetry', () => ({ tuiAgentToAgentKind: (agent: string) => agent })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: runtimeMocks.getRuntimeEnvironmentIdForWorktree +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: runtimeMocks.createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive: runtimeMocks.isWebRuntimeSessionActive +})) + import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session' describe('launchAiVaultSessionInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null) + runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) + runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue(true) mockState.tabsByWorktree = {} mockState.openFiles = [] mockState.browserTabsByWorktree = {} @@ -118,4 +135,45 @@ describe('launchAiVaultSessionInNewTab', () => { expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') expect(mockCreateTab).toHaveBeenCalledWith('wt-1', 'group-new') }) + + it('creates runtime-hosted resume terminals through the paired host', async () => { + runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('env-1') + runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true) + + const result = launchAiVaultSessionInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + targetGroupId: 'group-1', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'runtime' }, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'runtime' } + } + }) + + expect(result.tabId).toBeNull() + expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'env-1', + targetGroupId: 'group-1', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'runtime' }, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'runtime' } + }, + launchAgent: 'codex', + activate: true + }) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() + + if (result.tabId === null) { + await expect(result.runtimeLaunch).resolves.toBe(true) + } + expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') + }) }) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index a22f00271c8..e04e353452e 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -1,10 +1,19 @@ import { useAppStore } from '@/store' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' import type { AiVaultAgent } from '../../../shared/ai-vault-types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { TabSplitDirection } from '@/store/slices/tabs' +export type LaunchAiVaultSessionInNewTabResult = + | { tabId: string; groupId?: string } + | { tabId: null; groupId?: string; runtimeLaunch: Promise } + export function launchAiVaultSessionInNewTab(args: { agent: AiVaultAgent worktreeId: string @@ -13,9 +22,33 @@ export function launchAiVaultSessionInNewTab(args: { launchConfig?: SleepingAgentLaunchConfig targetGroupId?: string splitDirection?: TabSplitDirection -}): { tabId: string; groupId?: string } { +}): LaunchAiVaultSessionInNewTabResult { const store = useAppStore.getState() let targetGroupId = args.targetGroupId + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, args.worktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + const runtimeLaunch = createWebRuntimeSessionTerminal({ + worktreeId: args.worktreeId, + environmentId: runtimeEnvironmentId, + ...(targetGroupId ? { targetGroupId } : {}), + command: args.command, + ...(args.env ? { env: args.env } : {}), + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + launchAgent: args.agent, + activate: true + }).then((created) => { + if (created) { + useAppStore.getState().setActiveTabType('terminal') + } + return created + }) + return { + tabId: null, + ...(targetGroupId ? { groupId: targetGroupId } : {}), + runtimeLaunch + } + } + if (args.splitDirection && targetGroupId) { targetGroupId = store.createEmptySplitGroup(args.worktreeId, targetGroupId, args.splitDirection) ?? diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 4a1d7b36ff6..bf46bd40390 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -592,6 +592,104 @@ describe('web MiniMax preload API', () => { }) }) +describe('web AI Vault preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('routes session scans through the paired runtime host', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + const scanResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-04T00:00:00.000Z' + } + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: scanResult, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.aiVault.listSessions({ + executionHostScope: 'all', + limit: 25, + force: true, + scopePaths: ['/srv/app'] + }) + ).resolves.toEqual(scanResult) + expect(runtimeCalls).toEqual([ + { + method: 'aiVault.listSessions', + params: { + limit: 25, + force: true, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:web-env-1' + } + } + ]) + }) + + it('returns unavailable history for explicit non-runtime host scopes', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { sessions: [], issues: [], scannedAt: '2026-07-04T00:00:00.000Z' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.aiVault.listSessions({ executionHostScope: 'local' }) + ).resolves.toEqual({ + sessions: [], + issues: [ + expect.objectContaining({ + executionHostId: 'local', + agent: 'codex' + }) + ], + scannedAt: expect.any(String) + }) + expect(runtimeCalls).toEqual([]) + }) +}) + describe('web UI preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 30b97677b04..7696f454f75 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -10,6 +10,7 @@ import type { NativeChatAppendedMessages } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' import type { ComputerUsePermissionSetupResult, @@ -47,7 +48,13 @@ import { import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' -import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostScope, + normalizeExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { @@ -626,15 +633,7 @@ function createWebPreloadApi(): Partial { memory: { getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot()) }, - aiVault: { - listSessions: () => - Promise.resolve({ - sessions: [], - issues: [], - scannedAt: new Date().toISOString() - }), - onWindowFocused: () => () => {} - }, + aiVault: createAiVaultApi(), preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), @@ -1116,6 +1115,48 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim } } +function createAiVaultApi(): NonNullable['aiVault']> { + return { + listSessions: (args?: AiVaultListArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + const requestedScope = normalizeExecutionHostScope( + args?.executionHostScope ?? executionHostId + ) + if (requestedScope !== 'all' && requestedScope !== executionHostId) { + return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) + } + // Why: the browser client has no local filesystem; every history scan + // runs on the paired server and must be stamped as that runtime host. + return callRuntimeResult('aiVault.listSessions', { + limit: args?.limit, + force: args?.force, + scopePaths: args?.scopePaths, + executionHostId + }) + }, + onWindowFocused: () => noopUnsubscribe + } +} + +function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId, + agent: 'codex', + path: executionHostId, + message: translate( + 'auto.web.webPreloadApi.aiVaultUnavailableForHost', + 'Agent Session History is not available for this execution host.' + ) + } + ], + scannedAt: new Date().toISOString() + } +} + function createReposApi(): NonNullable['repos']> { return { list: async () => (await callRuntimeResult<{ repos: Repo[] }>('repo.list')).repos, From 864594ffdd14bd4866c0ff732bae958559af53eb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:36:19 -0700 Subject: [PATCH 063/152] Fix SSH state for paired remote clients + complete target re-adoption sweep (STA-1468) (#7767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sweep all persisted carriers of a removed SSH target id on re-adoption reassignSshTargetId re-pointed repos and worktree metas but left the old target id embedded in persisted session pty ids (ssh:@@pty-N in tabs, layouts, remoteSessionIdsByTabId), the startup reconnect list (activeConnectionIdsAtShutdown, replayed via ssh.connect at boot — the exact 'SSH target not found' in STA-1468), sleeping-agent resume records, provisioned project host setups, sidebar host-scope arrays, and relay pty leases. Any survivor resurfaces later as a failing connect or reattach. New ssh-target-id-migration module re-points every carrier in one pass, wired into reassignSshTargetId with per-carrier unit and store-level round-trip tests. Co-authored-by: Orca * Bridge SSH connection state to paired remote clients The SSH surface was desktop-only: ssh:state-changed went to the host's own BrowserWindow and the web client's ssh API was a no-op stub, so a paired client's reconnect overlay never learned the host connected and its target labels stayed empty (STA-1468 — overlay stuck on 'please connect' over a live terminal). - New sshStateChanged runtime client event, emitted from broadcastSshState through OrcaRuntimeService onto the existing clientEvents stream. - New ssh.listTargets / ssh.listRemovedTargetLabels RPC methods next to the previously unused ssh.getState / ssh.connect. - Web preload now routes listTargets / listRemovedTargetLabels / getState / connect to the paired host's runtime RPC instead of stubbing them. - useIpcEvents applies sshStateChanged on paired web clients through the same guarded path as desktop ssh.onStateChanged; desktop clients ignore the event since a foreign runtime's targets would pollute their local SSH store. Co-authored-by: Orca * Harden the SSH reconnect overlay against stale or unknown target state - Only present the destructive 'SSH host removed' state on positive evidence (a removal tombstone label, or a hydrated non-empty target list lacking the id). A client whose SSH state never hydrated has an empty labels map for every id and must not offer workspace removal. - After a failed Connect, resync target metadata so a stale overlay converges to the ghost/re-adopted state instead of offering the same failing Connect forever (the repeated 'SSH target not found' toast loop in STA-1468). Co-authored-by: Orca * Address CodeRabbit review on #7767 - Re-key workspaceSessionsByHostId partitions stored under a removed SSH host id during re-adoption (no writer keys partitions by ssh host today, but the schema tolerates it — re-key instead of stranding; live partition wins when both keys exist). - Track SSH target-list hydration explicitly (sshTargetsHydrated) instead of inferring it from a non-empty label map, so a legitimately empty target list still counts as removal evidence and a never-hydrated client still never offers destructive removal. - Apply the refreshed target list before the best-effort removed-labels fetch in the overlay resync, so a labels failure can't discard it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/ssh.test.ts | 33 +++++ src/main/ipc/ssh.ts | 19 ++- src/main/persistence.test.ts | 134 +++++++++++++++++ src/main/persistence.ts | 72 ++++++++- src/main/runtime/orca-runtime.ts | 7 + src/main/runtime/rpc/methods/ssh.test.ts | 37 ++++- src/main/runtime/rpc/methods/ssh.ts | 17 ++- src/main/runtime/runtime-rpc.ts | 2 + src/main/ssh/ssh-target-id-migration.test.ts | 139 ++++++++++++++++++ src/main/ssh/ssh-target-id-migration.ts | 116 +++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 9 +- .../TerminalSshReconnectOverlay.test.tsx | 60 +++++++- .../TerminalSshReconnectOverlay.tsx | 11 ++ src/renderer/src/hooks/useIpcEvents.ts | 96 +++++++----- .../src/runtime/runtime-client-events.ts | 1 + src/renderer/src/store/slices/ssh.test.ts | 14 +- src/renderer/src/store/slices/ssh.ts | 13 +- src/renderer/src/web/web-preload-api.ts | 41 +++++- src/shared/runtime-client-events.ts | 5 + 19 files changed, 763 insertions(+), 63 deletions(-) create mode 100644 src/main/ssh/ssh-target-id-migration.test.ts create mode 100644 src/main/ssh/ssh-target-id-migration.ts diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index a1db12efa51..cde245ee43f 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -778,6 +778,39 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { + const runtime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + notifySshStateChanged: vi.fn() + } + registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + // Why: paired remote clients only learn SSH state through this hook — + // without it their reconnect overlays never clear (STA-1468). + expect(runtime.notifySshStateChanged).toHaveBeenCalledWith( + 'ssh-1', + expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) + ) + }) + it('preserves active port forwards and live connections across handler re-registration', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 1fa15be1df4..58391c4c0cd 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -90,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un return registeredGetSshState?.(targetId) } +/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ +export function listRegisteredSshTargets(): SshTarget[] { + return sshStore?.listTargets() ?? [] +} + +/** Removed-target id → last known label, for ghost-host display on paired clients. */ +export function listRegisteredRemovedSshTargetLabels(): Record { + return sshStore?.listRemovedTargetLabels() ?? {} +} + export async function disconnectRegisteredSshTarget(targetId: string): Promise { if (!connectionManager) { return @@ -235,13 +245,14 @@ function broadcastSshState( if (isRuntimeOwnedSshTargetId(targetId)) { return } + const enrichedState = withSshRemotePlatform(targetId, state) const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:state-changed', { - targetId, - state: withSshRemotePlatform(targetId, state) - }) + win.webContents.send('ssh:state-changed', { targetId, state: enrichedState }) } + // Why: paired remote clients have no ssh:state-changed IPC; without this + // their terminals keep a stale reconnect overlay after the host connects. + currentRuntime?.notifySshStateChanged?.(targetId, enrichedState) } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 956711371b1..52f826935d4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3198,6 +3198,140 @@ describe('Store', () => { expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new') }) + it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'r1::/wt', + activeTabId: 'tab1', + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })] + }, + terminalLayoutsByTabId: {}, + remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' }, + activeConnectionIdsAtShutdown: ['ssh-old'] + }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' }) + store.updateUI({ + workspaceHostScope: 'ssh:ssh-old', + visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'], + workspaceHostOrder: ['ssh:ssh-old', 'local'] + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + const session = reloaded.getWorkspaceSession() + expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2') + expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' }) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new']) + expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1) + expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0) + const ui = reloaded.getUI() + expect(ui.workspaceHostScope).toBe('ssh:ssh-new') + expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new']) + expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local']) + }) + + it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { + const store = await createStore() + store.setWorkspaceSession( + { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })] + }, + terminalLayoutsByTabId: {} + }, + 'ssh:ssh-old' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + // Old-key partition is gone; the re-keyed one carries migrated pty ids. + expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe( + 'ssh:ssh-new@@pty-9' + ) + }) + + it('reassignSshTargetId keeps the live partition when both host keys exist', async () => { + const store = await createStore() + const baseSession = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + terminalLayoutsByTabId: {} + } + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/dead': [] } }, + 'ssh:ssh-old' + ) + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/live': [] } }, + 'ssh:ssh-new' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] }) + }) + + it('reassignSshTargetId re-points an independent provisioned host setup', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'cloud-project::ssh-old', + setupMethod: 'provisioned' + }) + + // Meta-only re-adoption (no repo pinned to the old id) must still migrate + // the provisioned setup, or new worktrees would be born on a dead host id. + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old') + expect(provisioned?.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId drops a stale setup when the new host already has one', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'setup-old', + setupMethod: 'provisioned' + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-new', + setupId: 'setup-new', + setupMethod: 'provisioned' + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined() + expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new') + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2caa3de28e1..b10fa4fe910 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -113,6 +113,10 @@ import { type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh/ssh-target-id-migration' import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, @@ -6131,13 +6135,71 @@ export class Store { metaChanged = true } } - // Why: repo-row rewrites can affect host-setup compatibility, but meta-only - // rewrites cannot — keep that sync under the repo gate. Persist whenever - // either repos OR metas changed, so meta-only re-points aren't lost on quit. - if (repoCount > 0) { + // Why: the old id also survives in session pty ids, the startup reconnect + // list, sleeping-agent records, host setups, host-scope UI, and pty leases; + // any un-migrated carrier later throws `SSH target not found` (STA-1468). + let carrierChanged = migrateWorkspaceSessionSshTargetId( + this.state.workspaceSession, + oldTargetId, + newTargetId + ) + for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { + if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { + carrierChanged = true + } + } + // Why: partitions are read by host id, so one stored under the removed id + // would be orphaned. No writer keys partitions by ssh host today, but the + // schema tolerates it — re-key rather than strand it. If the new key + // already has a partition, that one is live; drop the dead old one. + const partitions = this.state.workspaceSessionsByHostId + const oldPartition = partitions?.[oldHostId] + if (partitions && oldPartition) { + delete partitions[oldHostId] + partitions[newHostId] ??= oldPartition + carrierChanged = true + } + if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) { + carrierChanged = true + } + for (const lease of this.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === oldTargetId) { + lease.targetId = newTargetId + carrierChanged = true + } + } + let setupsChanged = false + const keptSetups: ProjectHostSetup[] = [] + for (const setup of this.state.projectHostSetups) { + if (setup.hostId !== oldHostId) { + keptSetups.push(setup) + continue + } + const duplicate = this.state.projectHostSetups.some( + (entry) => + entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId + ) + // Why: a setup already exists for the re-added host — the old row is a + // stale ghost that would violate the (projectId, hostId) uniqueness. + if (duplicate) { + setupsChanged = true + continue + } + setup.hostId = newHostId + setup.updatedAt = Date.now() + keptSetups.push(setup) + setupsChanged = true + } + if (setupsChanged) { + this.state.projectHostSetups = keptSetups + } + // Why: repo-row and host-setup rewrites can affect host-setup compatibility, + // but meta-only rewrites cannot — keep that sync under this gate. Persist + // whenever anything changed, so partial re-points aren't lost on quit. + if (repoCount > 0 || setupsChanged) { this.syncProjectHostSetupCompatibilityState() } - if (repoCount > 0 || metaChanged) { + if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) { this.scheduleSave() } return repoCount diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1296ef45df8..bb09d201a06 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -130,6 +130,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/executi import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import type { SshConnectionState } from '../../shared/ssh-types' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -2806,6 +2807,12 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'reposChanged' }) } + // Why: SSH state changes originate in main's ssh handlers, not in runtime + // methods, so they need a public entry point onto the client-event stream. + notifySshStateChanged(targetId: string, state: SshConnectionState): void { + this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 94791a7fdd3..8efab508345 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -4,14 +4,23 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { SSH_METHODS } from './ssh' -const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({ +const { + connectRegisteredSshTargetMock, + getRegisteredSshStateMock, + listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabelsMock +} = vi.hoisted(() => ({ connectRegisteredSshTargetMock: vi.fn(), - getRegisteredSshStateMock: vi.fn() + getRegisteredSshStateMock: vi.fn(), + listRegisteredSshTargetsMock: vi.fn(), + listRegisteredRemovedSshTargetLabelsMock: vi.fn() })) vi.mock('../../../ipc/ssh', () => ({ connectRegisteredSshTarget: connectRegisteredSshTargetMock, - getRegisteredSshState: getRegisteredSshStateMock + getRegisteredSshState: getRegisteredSshStateMock, + listRegisteredSshTargets: listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock })) function makeRequest(method: string, params?: unknown): RpcRequest { @@ -62,4 +71,26 @@ describe('ssh RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { state: null } }) }) + + it('lists the registered SSH targets for paired clients', async () => { + const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }] + listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargets')) + + expect(response).toMatchObject({ ok: true, result: { targets } }) + }) + + it('lists removed-target labels for ghost-host display on paired clients', async () => { + const labels = { 'ssh-old': 'Dev box' } + listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels')) + + expect(response).toMatchObject({ ok: true, result: { labels } }) + }) }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e4d836cedff..6de8634a881 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh' +import { + connectRegisteredSshTarget, + getRegisteredSshState, + listRegisteredRemovedSshTargetLabels, + listRegisteredSshTargets +} from '../../../ipc/ssh' import { defineMethod, type RpcMethod } from '../core' const SshTarget = z.object({ @@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [ name: 'ssh.connect', params: SshTarget, handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) }) + }), + defineMethod({ + name: 'ssh.listTargets', + params: null, + handler: () => ({ targets: listRegisteredSshTargets() }) + }), + defineMethod({ + name: 'ssh.listRemovedTargetLabels', + params: null, + handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a5418500c1d..c2f949c2733 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -308,6 +308,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'settings.update', 'ssh.connect', 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargets', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts new file mode 100644 index 00000000000..fa6507d6260 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest' +import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh-target-id-migration' + +const OLD_ID = 'ssh-1783337351840-ohabf0' +const NEW_ID = 'ssh-1783400000000-fresh1' + +const makeTab = (overrides: Partial = {}): TerminalTab => ({ + id: 'tab1', + ptyId: null, + worktreeId: 'r1::/wt', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides +}) + +const makeSession = (overrides: Partial = {}): WorkspaceSessionState => ({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides +}) + +describe('migrateWorkspaceSessionSshTargetId', () => { + it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => { + const session = makeSession({ + tabsByWorktree: { + 'r1::/wt': [ + makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }), + makeTab({ id: 'tab2', ptyId: 'local-pty-7' }), + makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` } + } + }, + remoteSessionIdsByTabId: { + tab1: `ssh:${OLD_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + + const tabs = session.tabsByWorktree['r1::/wt'] + expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`) + // Local and other-target pty ids must be untouched. + expect(tabs[1].ptyId).toBe('local-pty-7') + expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`) + expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + 'leaf-1': `ssh:${NEW_ID}@@pty-3` + }) + expect(session.remoteSessionIdsByTabId).toEqual({ + tab1: `ssh:${NEW_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + }) + }) + + it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => { + const session = makeSession({ + activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated']) + }) + + it('re-points sleeping agent records pinned to the old connection', () => { + const session = makeSession({ + sleepingAgentSessionsByPaneKey: { + 'tab1:leaf-1': { + paneKey: 'tab1:leaf-1', + worktreeId: 'r1::/wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 's-1' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: OLD_ID + } + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID) + }) + + it('returns false when nothing references the old id', () => { + const session = makeSession({ + tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] }, + activeConnectionIdsAtShutdown: ['ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated']) + }) +}) + +describe('migrateUiHostScopeSshTargetId', () => { + const makeUi = (overrides: Partial): PersistedUIState => + ({ ...overrides }) as PersistedUIState + + it('re-points scope, visible hosts, and host order, deduping collisions', () => { + const ui = makeUi({ + workspaceHostScope: `ssh:${OLD_ID}`, + visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`], + workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true) + expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`) + expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`]) + expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local']) + }) + + it('returns false when the old host id appears nowhere', () => { + const ui = makeUi({ + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local'], + workspaceHostOrder: ['local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts new file mode 100644 index 00000000000..a917c4393e9 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -0,0 +1,116 @@ +import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' +import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' +import { toSshExecutionHostId } from '../../shared/execution-host' + +/** + * Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts). + * + * reassignSshTargetId re-points repos/worktree metas, but the removed target's + * id is also embedded in other persisted state: app-scoped SSH pty ids + * ("ssh:@@pty-N") inside the workspace session, the startup + * reconnect list, sleeping-agent resume records, and the sidebar host-scope + * arrays. Any survivor resurfaces later as `SSH target "" not found` at + * connect/reattach time (STA-1468), so every carrier must migrate together. + * + * All helpers mutate in place (matching how the Store edits this.state) and + * return whether anything changed so callers can gate scheduleSave. + */ + +function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed || parsed.connectionId !== oldTargetId) { + return null + } + return toAppSshPtyId(newTargetId, parsed.relayPtyId) +} + +function rewriteSshPtyIdRecordValues( + record: Record | undefined, + oldTargetId: string, + newTargetId: string +): boolean { + if (!record) { + return false + } + let changed = false + for (const [key, ptyId] of Object.entries(record)) { + const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId) + if (next) { + record[key] = next + changed = true + } + } + return changed +} + +/** Re-point every old-target-id carrier inside one workspace session partition. */ +export function migrateWorkspaceSessionSshTargetId( + session: WorkspaceSessionState, + oldTargetId: string, + newTargetId: string +): boolean { + let changed = false + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!tab.ptyId) { + continue + } + const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId) + if (next) { + tab.ptyId = next + changed = true + } + } + } + for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { + if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) { + changed = true + } + } + if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) { + changed = true + } + if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) { + session.activeConnectionIdsAtShutdown = [ + ...new Set( + session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id)) + ) + ] + changed = true + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.connectionId === oldTargetId) { + record.connectionId = newTargetId + changed = true + } + } + return changed +} + +/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */ +export function migrateUiHostScopeSshTargetId( + ui: PersistedUIState, + oldTargetId: string, + newTargetId: string +): boolean { + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let changed = false + if (ui.workspaceHostScope === oldHostId) { + ui.workspaceHostScope = newHostId + changed = true + } + if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) { + ui.visibleWorkspaceHostIds = [ + ...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + if (ui.workspaceHostOrder?.includes(oldHostId)) { + ui.workspaceHostOrder = [ + ...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + return changed +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e73d1964619..90839885add 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -323,8 +323,15 @@ export default function TerminalPane({ // Why: the target was removed entirely (a ghost) when it's no longer a known // SSH target. Reconnecting to it can only fail ("SSH target not found"), so // the overlay must offer to remove the workspace instead of Connect. + // Removal needs positive evidence — a removal tombstone label, or a + // successfully hydrated target list (even an empty one) that lacks the id. + // A client whose SSH state never hydrated (paired client on an older host) + // must not offer workspace removal off that ignorance. const sshReconnectTargetRemoved = useAppStore((store) => - sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false + sshReconnectTargetId + ? store.removedSshTargetLabels.has(sshReconnectTargetId) || + (store.sshTargetsHydrated && !store.sshTargetLabels.has(sshReconnectTargetId)) + : false ) useVisibleTerminalTabClaim({ isVisible, tabId }) diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index 4653b88e46c..3b891b342fa 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -32,12 +32,18 @@ vi.mock('@/i18n/i18n', () => ({ fallback.replace('{{value0}}', values?.value0 ?? '') })) -function installSshConnect(connect: ReturnType): void { +function installSshConnect( + connect: ReturnType, + overrides: Record> = {} +): void { Object.defineProperty(window, 'api', { configurable: true, value: { ssh: { - connect + connect, + listTargets: vi.fn().mockResolvedValue([]), + listRemovedTargetLabels: vi.fn().mockResolvedValue({}), + ...overrides } } }) @@ -110,6 +116,56 @@ describe('TerminalSshReconnectOverlay', () => { expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled() }) + it('resyncs target metadata after a failed connect so a stale overlay converges', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi.fn().mockResolvedValue({ 'ssh-dead': 'devbox (removed)' }) + installSshConnect(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + // Why: the metadata refresh is what flips TerminalPane's targetRemoved + // derivation, replacing the failing Connect loop with the ghost-host UI. + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().removedSshTargetLabels.get('ssh-dead')).toBe('devbox (removed)') + }) + }) + + it('still applies the target list when the removed-labels refresh fails', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi.fn().mockRejectedValue(new Error('unavailable')) + installSshConnect(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + // Why: a removed-labels failure must not discard the refreshed target + // list — it alone is enough evidence for targetRemoved to converge. + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().sshTargetsHydrated).toBe(true) + }) + }) + it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => { const connect = vi.fn().mockResolvedValue(undefined) installSshConnect(connect) diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index b2be21b6627..8f3071de453 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -100,6 +100,17 @@ export function TerminalSshReconnectOverlay({ 'SSH connection failed' ) ) + // Why: a failed connect usually means the renderer's target metadata is + // stale (target removed, or re-added under a new id). Resync it so the + // overlay converges to the ghost/re-adopted state instead of offering + // the same failing Connect forever (STA-1468). Apply the target list + // first — a removed-labels failure must not discard it. + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) } finally { if (mountedRef.current) { setConnecting(false) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3b327b924d4..ac867b3c3a3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -83,6 +83,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' import { detectLanguage } from '@/lib/language-detect' @@ -963,11 +964,26 @@ export function useIpcEvents(): void { } }) + // Assigned later in this effect, next to the ssh.onStateChanged wiring; + // events can't fire before that because subscriptions attach asynchronously. + let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null = + null + const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => { if (event.type === 'reposChanged') { runtimeProjectRefreshScheduler.request(environmentId) return } + if (event.type === 'sshStateChanged') { + // Why: only a paired web client routes its ssh.* API to the host that + // emitted this event, so only there can the store mirror host SSH state + // (STA-1468). Desktop clients own a local SSH surface a foreign + // runtime's targets would pollute, so they ignore it. + if (isPairedWebClientWindow()) { + handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state }) + } + return + } if (event.type === 'worktreesChanged') { void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId }) @@ -2667,49 +2683,49 @@ export function useIpcEvents(): void { let sshTargetStateEventId = 0 const latestSshTargetStateEventByTargetId = new Map() - unsubs.push( - window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => { - const store = useAppStore.getState() - const state = data.state as SshConnectionState - const stateEventId = ++sshTargetStateEventId - latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) - if (!store.sshTargetLabels.has(data.targetId)) { - // Why: targets added after boot aren't in the labels map, while - // removed targets can still race a final disconnect event. Confirm - // with main before mutating renderer state for an unknown target id. - window.api.ssh - .listTargets() - // Why: this refresh is now a deletion guard, not just a label fetch. - // Retry once so a transient IPC failure does not drop a real added-target event. - .catch(() => window.api.ssh.listTargets()) - .then((targets) => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { - return - } + handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { + const store = useAppStore.getState() + const state = data.state as SshConnectionState + const stateEventId = ++sshTargetStateEventId + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) + if (!store.sshTargetLabels.has(data.targetId)) { + // Why: targets added after boot aren't in the labels map, while + // removed targets can still race a final disconnect event. Confirm + // with main before mutating renderer state for an unknown target id. + window.api.ssh + .listTargets() + // Why: this refresh is now a deletion guard, not just a label fetch. + // Retry once so a transient IPC failure does not drop a real added-target event. + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + // Why: disconnect/state events can race after target removal. + // Treat absence from main's target list as deletion, not a new target. + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state) + }) + .catch(() => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { latestSshTargetStateEventByTargetId.delete(data.targetId) - const latestStore = useAppStore.getState() - if (!targets.some((target) => target.id === data.targetId)) { - // Why: disconnect/state events can race after target removal. - // Treat absence from main's target list as deletion, not a new target. - latestStore.clearRemovedSshTargetState(data.targetId) - return - } - latestStore.setSshTargetsMetadata(targets) applySshConnectionStateChange(data.targetId, state) - }) - .catch(() => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) - } - }) - return - } + } + }) + return + } - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) - }) - ) + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state) + } + + unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) let remoteWorkspaceClientId: string | null = null let remoteWorkspaceClientIdPromise: Promise | null = null diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 51827546a2e..0d44ddcbf57 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -53,6 +53,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' ) diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index af8f17fd30d..ef5ebf67752 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -146,7 +146,7 @@ describe('createSshSlice', () => { it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { const store = createTestStore() const labels = new Map([['ssh-1', 'Remote']]) - store.setState({ sshTargetLabels: labels }) + store.setState({ sshTargetLabels: labels, sshTargetsHydrated: true }) const previousState = store.getState() store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) @@ -155,6 +155,18 @@ describe('createSshSlice', () => { expect(store.getState().sshTargetLabels).toBe(labels) }) + it('marks targets hydrated on the first load, even when the list is empty', () => { + const store = createTestStore() + expect(store.getState().sshTargetsHydrated).toBe(false) + + store.getState().setSshTargetsMetadata([]) + + // Why: an empty target set is still positive knowledge — the overlay's + // targetRemoved derivation may only trust absence after a real load. + expect(store.getState().sshTargetsHydrated).toBe(true) + expect(store.getState().sshTargetLabels.size).toBe(0) + }) + it('keeps SSH connection state references stable when duplicate state arrives', () => { const store = createTestStore() const sshConnectionStates = new Map([ diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 8b98b77b623..6b80e054353 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -37,6 +37,11 @@ export type SshSlice = { * tombstones). Lets ghost-host UI show a friendly name instead of the raw id * for a workspace still pinned to a deleted target. */ removedSshTargetLabels: Map + /** True once a target list actually loaded (even an empty one). Distinguishes + * "this client knows the target set" from "never hydrated" (e.g. a paired + * client on a host without the ssh RPC), so absence from sshTargetLabels + * only counts as removal evidence when this is set. */ + sshTargetsHydrated: boolean remoteWorkspaceHydratedTargetIds: Set remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] @@ -71,6 +76,7 @@ export const createSshSlice: StateCreator = (set) => sshConnectionStates: new Map(), sshTargetLabels: new Map(), removedSshTargetLabels: new Map(), + sshTargetsHydrated: false, remoteWorkspaceHydratedTargetIds: new Set(), remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], @@ -101,10 +107,13 @@ export const createSshSlice: StateCreator = (set) => setSshTargetsMetadata: (targets) => set((s) => { if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { - return s + // Why: an unchanged (even empty) list is still a successful load — the + // hydration flag must flip on the first fetch of an empty target set. + return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true } } return { - sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) + sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), + sshTargetsHydrated: true } }), clearRemovedSshTargetState: (targetId) => diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7696f454f75..af231d8062c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, getDefaultSettings, @@ -2659,19 +2660,51 @@ function createPtyApi(): NonNullable['pty']> { function createSshApi(): NonNullable['ssh']> { return { - listTargets: () => Promise.resolve([]), - listRemovedTargetLabels: () => Promise.resolve({}), + // Why: SSH connections are owned by the paired host. Read/connect route to + // its runtime RPC so remote worktrees can show real connection state and + // reconnect (STA-1468); target management stays desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>('ssh.listTargets') + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, addTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), updateTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), importConfig: () => Promise.resolve([]), - connect: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, disconnect: () => Promise.resolve(), terminateSessions: () => Promise.resolve(), resetRelay: () => Promise.resolve(), - getState: () => Promise.resolve(null), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, needsPassphrasePrompt: () => Promise.resolve(false), testConnection: () => Promise.resolve({ diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index f73c4010f4e..2ebc69005f5 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -4,10 +4,15 @@ import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './types' +import type { SshConnectionState } from './ssh-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + // Why: SSH connections live on the runtime host; paired clients have no IPC + // channel for ssh:state-changed, so without this event their reconnect + // overlays never learn the host connected (STA-1468). + | { type: 'sshStateChanged'; targetId: string; state: SshConnectionState } | { type: 'linearLinkedIssueUpdated' worktreeId: string From b665708c7ff473a237222c968ff0117c3065db78 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:01:06 -0700 Subject: [PATCH 064/152] fix(ssh): make fs.listFiles cancellable and single-flight per relay client (#7769) Co-authored-by: Orca --- src/main/ipc/filesystem.test.ts | 35 +++ src/main/ipc/filesystem.ts | 56 +++-- .../providers/ssh-filesystem-provider.test.ts | 35 ++- src/main/providers/ssh-filesystem-provider.ts | 12 +- src/main/providers/types.ts | 5 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 3 + src/relay/fs-handler-git-fallback.ts | 29 ++- .../fs-handler-list-files-cancel.test.ts | 127 ++++++++++ src/relay/fs-handler-list-files.ts | 29 ++- src/relay/fs-handler-readdir-fallback.ts | 6 +- src/relay/fs-handler.ts | 38 ++- .../fs-list-files-cancel.integration.test.ts | 209 +++++++++++++++++ .../fs-list-files-scan-coordinator.test.ts | 222 ++++++++++++++++++ src/relay/fs-list-files-scan-coordinator.ts | 104 ++++++++ .../quick-open-file-list.react.test.tsx | 35 ++- .../src/components/quick-open-file-list.ts | 36 +-- .../src/runtime/runtime-file-client.test.ts | 58 +++++ .../src/runtime/runtime-file-client.ts | 23 +- src/renderer/src/web/web-preload-api.ts | 4 + src/shared/file-listing-cancellation.ts | 38 +++ src/shared/quick-open-readdir-walk.test.ts | 32 +++ src/shared/quick-open-readdir-walk.ts | 11 +- 23 files changed, 1090 insertions(+), 59 deletions(-) create mode 100644 src/relay/fs-handler-list-files-cancel.test.ts create mode 100644 src/relay/fs-list-files-cancel.integration.test.ts create mode 100644 src/relay/fs-list-files-scan-coordinator.test.ts create mode 100644 src/relay/fs-list-files-scan-coordinator.ts create mode 100644 src/shared/file-listing-cancellation.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 25894ffb723..072f8239dcb 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -2316,4 +2316,39 @@ describe('registerFilesystemHandlers', () => { excludePaths: ['/home/user/repo/worktrees/feature'] }) }) + + // Why #7721: without a cancel path, every workspace switch left the previous + // workspace's full-tree SSH scan running, stacking scans on the relay until + // interactive fs.readDir/fs.stat starved past their 30s timeout. + it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => { + let capturedSignal: AbortSignal | undefined + const listFilesMock = vi.fn( + (_rootPath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + capturedSignal = options.signal + options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), { + once: true + }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) + + registerFilesystemHandlers(store as never) + + const pending = handlers.get('fs:listFiles')!(null, { + rootPath: '/home/user/repo', + connectionId: 'conn-1', + requestToken: 'token-1' + }) as Promise + + expect(capturedSignal?.aborted).toBe(false) + await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' }) + expect(capturedSignal?.aborted).toBe(true) + await expect(pending).rejects.toThrow('listing cancelled') + + // Unknown or already-settled tokens are a no-op, not an error. + expect(() => + handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' }) + ).not.toThrow() + }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 85c0ce56e31..7e7006a375e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1026,30 +1026,58 @@ export function registerFilesystemHandlers( ) // ─── List all files (for quick-open) ───────────────────── + // Why #7721: keyed by renderer-generated token so a workspace switch can + // abort the previous workspace's full-tree scan (SSH relays otherwise stack + // scans that starve interactive fs.readDir/fs.stat past their 30s timeout). + const listFilesCancellations = new Map() ipcMain.handle( 'fs:listFiles', async ( _event, - args: { rootPath: string; connectionId?: string; excludePaths?: string[] } + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + } ): Promise => { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: when the SSH connection is not yet established (cold start) or - // temporarily disconnected, return [] so quick-open shows "No matching - // files" instead of an error banner. The file list will repopulate when - // the user re-opens quick-open after the connection is restored. - if (!provider) { - return [] + const controller = args.requestToken ? new AbortController() : null + if (controller && args.requestToken) { + listFilesCancellations.set(args.requestToken, controller) + } + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: when the SSH connection is not yet established (cold start) or + // temporarily disconnected, return [] so quick-open shows "No matching + // files" instead of an error banner. The file list will repopulate when + // the user re-opens quick-open after the connection is restored. + if (!provider) { + return [] + } + // Why: forward excludePaths through to the remote provider. + // Dropping it here would silently double-scan nested linked worktrees + // over SSH and contribute to timeout-induced partial results. + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + signal: controller?.signal + }) } - // Why: forward excludePaths through to the remote provider. - // Dropping it here would silently double-scan nested linked worktrees - // over SSH and contribute to timeout-induced partial results. - return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths }) + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths) + } finally { + if (args.requestToken) { + listFilesCancellations.delete(args.requestToken) + } } - return listQuickOpenFiles(args.rootPath, store, args.excludePaths) } ) + ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => { + // Why: best-effort — the entry is gone once the listing settles, and + // local scans are fast enough to simply let them finish. + listFilesCancellations.get(args.requestToken)?.abort() + }) + // ─── Git operations ───────────────────────────────────── ipcMain.handle( 'git:status', diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 6d4170209e7..d35460bd489 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -417,7 +417,11 @@ describe('SshFilesystemProvider', () => { it('listFiles sends fs.listFiles request', async () => { mux.request.mockResolvedValue(['src/index.ts', 'package.json']) const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: undefined } + ) expect(result).toEqual(['src/index.ts', 'package.json']) }) @@ -426,16 +430,35 @@ describe('SshFilesystemProvider', () => { await provider.listFiles('/home/user/project', { excludePaths: ['/home/user/project/worktrees/b'] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] - }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { + rootPath: '/home/user/project', + excludePaths: ['/home/user/project/worktrees/b'] + }, + { signal: undefined } + ) }) it('listFiles omits excludePaths when empty', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: undefined } + ) + }) + + it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { + mux.request.mockResolvedValue([]) + const controller = new AbortController() + await provider.listFiles('/home/user/project', { signal: controller.signal }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: controller.signal } + ) }) describe('watch', () => { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 3adeeec6cb8..ce5256d2744 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -283,12 +283,20 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.search', opts)) as SearchResult } - async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise { + async listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } - return (await this.mux.request('fs.listFiles', params)) as string[] + // Why #7721: the signal lets a workspace switch send rpc.cancel so the + // relay aborts the full-tree scan instead of stacking abandoned scans + // that starve interactive fs.readDir/fs.stat on the shared SSH channel. + return (await this.mux.request('fs.listFiles', params, { + signal: options?.signal + })) as string[] } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 28d5d1d9447..c8e2680366b 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -197,7 +197,10 @@ export type IFilesystemProvider = { copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise - listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 35ac8802402..78d7eb38e79 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2247,7 +2247,9 @@ export type PreloadApi = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }) => Promise + cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise importExternalPaths: (args: { sourcePaths: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index b3b6a2978db..e47ae057cd8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2648,7 +2648,10 @@ const api = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), search: (args: { query: string rootPath: string diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index da1f67c7ffa..8a98b9fa444 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -7,6 +7,7 @@ * a git-focused app. */ import { spawn } from 'node:child_process' +import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk' @@ -31,8 +32,13 @@ import { buildRelayCommandEnv } from './relay-command-env' */ export function listFilesWithGit( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } const gitPaths = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { @@ -145,7 +151,7 @@ export function listFilesWithGit( }) } - const killSurvivors = (): void => { + const killSurvivors = (reason: string): void => { // Why: Promise.all returns after the first failed pass, but the sibling // git process can keep streaming on SSH unless we cancel it explicitly. for (const entry of children) { @@ -155,22 +161,35 @@ export function listFilesWithGit( if (entry.child.exitCode === null && entry.child.signalCode === null) { entry.child.kill() } - entry.reject(new Error('git ls-files canceled after sibling failure')) + entry.reject(new Error(reason)) } } + // Why: a cancelled scan (workspace switch, superseded request) must stop + // its git children right away instead of streaming a huge tree the caller + // has already abandoned over the shared SSH channel. + const onAbort = (): void => killSurvivors('git ls-files cancelled') + signal?.addEventListener('abort', onAbort, { once: true }) + return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) .then(() => expandQuickOpenGitFilesWithNestedRepos({ rootPath, gitPaths, - excludePathPrefixes + excludePathPrefixes, + signal }) ) .catch((err) => { - killSurvivors() + killSurvivors('git ls-files canceled after sibling failure') + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } throw err }) + .finally(() => { + signal?.removeEventListener('abort', onAbort) + }) } /** diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts new file mode 100644 index 00000000000..02cd8da6afc --- /dev/null +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -0,0 +1,127 @@ +/** + * Cancellation behavior of the relay file-list scanners (#7721): an aborted + * scan must kill its child processes immediately and reject with a + * cancellation error instead of streaming an abandoned tree to completion. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { listFilesWithRg } from './fs-handler-list-files' +import { listFilesWithGit } from './fs-handler-git-fallback' +import { isFileListingCancellation } from '../shared/file-listing-cancellation' + +function createMockProcess(): ChildProcess { + const p = new EventEmitter() as unknown as ChildProcess + ;(p as unknown as Record).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + ;(p as unknown as Record).exitCode = null + ;(p as unknown as Record).signalCode = null + return p +} + +describe('relay list-files cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc + ) + + const controller = new AbortController() + const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) + + // Partial output before the abort — must be discarded, not resolved. + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).toHaveBeenCalled() + + // Late close events after cancellation must not fire anything. + primaryProc.emit('close', null, 'SIGTERM') + ignoredProc.emit('close', null, 'SIGTERM') + }) + + it('listFilesWithRg rejects without spawning when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + listFilesWithRg('/remote/root', [], { signal: controller.signal }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc + ) + + const controller = new AbortController() + const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + primaryProc.emit('close', 0, null) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.emit('close', 0, null) + }, 5) + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js']) + }) + + it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => { + const procs: ChildProcess[] = [] + spawnMock.mockImplementation(() => { + const proc = createMockProcess() + procs.push(proc) + return proc + }) + + const controller = new AbortController() + const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal }) + + expect(procs).toHaveLength(2) + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(procs[0].kill).toHaveBeenCalled() + expect(procs[1].kill).toHaveBeenCalled() + }) + + it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + listFilesWithGit('/remote/root', [], { signal: controller.signal }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 99e3eb97ff9..bbe7912ee61 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -16,6 +16,7 @@ * denied on a single subdir is expected on home-dir roots) */ import { spawn, type ChildProcess } from 'node:child_process' +import { fileListingCancellationError } from '../shared/file-listing-cancellation' import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, @@ -27,8 +28,13 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } return new Promise((resolve, reject) => { const files = new Set() let done = false @@ -177,7 +183,7 @@ export function listFilesWithRg( child.once('close', handleClose) }) - const killSurvivors = (): void => { + const killSurvivors = (reason: string): void => { // Why: when one pass rejects, Promise.all surfaces the error immediately // but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it // so repeated Quick Open opens don't pile up orphan rg processes on the @@ -189,16 +195,30 @@ export function listFilesWithRg( if (entry.child.exitCode === null && entry.child.signalCode === null) { entry.child.kill() } - entry.reject(new Error('rg list canceled after sibling failure')) + entry.reject(new Error(reason)) } } + // Why: a cancelled scan (workspace switch, superseded request) must stop + // its rg children immediately instead of letting them walk the tree to + // completion and flood the relay with stdout it will only discard. + const onAbort = (): void => { + if (done) { + return + } + done = true + killSurvivors('rg list cancelled') + reject(fileListingCancellationError(signal)) + } + signal?.addEventListener('abort', onAbort, { once: true }) + Promise.all([runPass(primary), runPass(ignoredPass)]) .then(() => { if (done) { return } done = true + signal?.removeEventListener('abort', onAbort) resolve(Array.from(files)) }) .catch((err) => { @@ -206,7 +226,8 @@ export function listFilesWithRg( return } done = true - killSurvivors() + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list canceled after sibling failure') reject(err instanceof Error ? err : new Error(String(err))) }) }) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index e49609996d1..d7c9172183b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -23,10 +23,12 @@ import { */ export async function listFilesWithReaddir( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, - budget: createQuickOpenReaddirBudget() + budget: createQuickOpenReaddirBudget(), + signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index c6564c51fbe..f6417b38d3c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -17,6 +17,11 @@ import { } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' +import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' +import { + isFileListingCancellation, + throwIfFileListingCancelled +} from '../shared/file-listing-cancellation' import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' @@ -82,6 +87,7 @@ export class FsHandler { private dispatcher: RelayDispatcher private watches = new Map() private streamRegistry = new RelayStreamRegistry() + private listFilesScans = new ListFilesScanCoordinator() constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher @@ -114,7 +120,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) - this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) + this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context)) @@ -331,16 +337,33 @@ export class FsHandler { }) } - private async listFiles(params: Record): Promise { + private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so // the request still succeeds (older apps omit the field entirely). const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths) + // Why #7721: full-tree scans are the relay's most expensive request; the + // coordinator caps them at one per client, coalescing duplicates and + // aborting a stale scan when the workspace changes or the host cancels. + return this.listFilesScans.run({ + clientId: context?.clientId ?? 0, + key: JSON.stringify([rootPath, excludePathPrefixes]), + signal: context?.signal, + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + }) + } + + private async runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal + ): Promise { const rgAvailable = await checkRgAvailable() + throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -361,7 +384,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -375,8 +398,13 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) } catch (err) { + // Why: a cancelled scan is not an rg-availability problem; wrapping it + // in install-rg guidance would surface bogus advice on the client. + if (isFileListingCancellation(err)) { + throw err + } throw new Error(await buildInstallRgMessage(err)) } } diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts new file mode 100644 index 00000000000..588ddb5fe51 --- /dev/null +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -0,0 +1,209 @@ +/** + * End-to-end in-process regression tests for #7721: cancellable fs.listFiles. + * + * Wires the client-side SshChannelMultiplexer to the relay-side + * RelayDispatcher + FsHandler through an in-memory pipe (no SSH). The scan + * body is a controllable fake so the tests are deterministic: + * - aborting the client request sends rpc.cancel and stops the relay scan, + * - interactive fs.readDir is served while a scan is in flight, + * - a scan for a different workspace supersedes the previous one, + * - identical concurrent requests coalesce into one scan. + */ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +const { fakeListFiles } = vi.hoisted(() => { + type ScanRecord = { + rootPath: string + signal: AbortSignal | undefined + resolve: (files: string[]) => void + } + const scans: ScanRecord[] = [] + const fakeListFiles = Object.assign( + vi.fn( + ( + rootPath: string, + _excludes: readonly string[] = [], + options: { signal?: AbortSignal } = {} + ) => + new Promise((resolve, reject) => { + scans.push({ rootPath, signal: options.signal, resolve }) + options.signal?.addEventListener( + 'abort', + () => + // Mirror the real scanners: surface the abort reason (e.g. the + // "superseded" error) so the dispatcher reports it to the host. + reject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error('File listing cancelled') + ), + { once: true } + ) + }) + ), + { scans } + ) + return { fakeListFiles } +}) + +vi.mock('./fs-handler-utils', async (importOriginal) => { + const original = (await importOriginal()) as Record + return { + ...original, + checkRgAvailable: () => Promise.resolve(true), + listFilesWithRg: fakeListFiles + } +}) + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' + +async function flushPipe(): Promise { + // The in-memory pipe defers each hop with setImmediate; a few macrotask + // turns guarantee request/notification frames have crossed both directions. + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +describe('Integration: cancellable fs.listFiles (#7721)', () => { + let tmpDir: string + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let fsHandler: FsHandler + + beforeEach(() => { + fakeListFiles.mockClear() + fakeListFiles.scans.length = 0 + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-listfiles-cancel-')) + + let relayFeedFn: (data: Buffer) => void + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + setImmediate(() => relayFeedFn?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + dispatcher = new RelayDispatcher((data: Buffer) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }) + relayFeedFn = (data: Buffer) => dispatcher.feed(data) + fsHandler = new FsHandler(dispatcher, new RelayContext()) + mux = new SshChannelMultiplexer(clientTransport) + }) + + afterEach(async () => { + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('serves fs.readDir while a full-tree scan is in flight', async () => { + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + + const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + // The interactive request must complete while the scan is still pending. + const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[] + expect(entries.map((e) => e.name)).toEqual(['a.txt']) + + fakeListFiles.scans[0].resolve(['src/index.ts']) + await expect(scanPromise).resolves.toEqual(['src/index.ts']) + }) + + it('client abort sends rpc.cancel and stops the relay-side scan', async () => { + const controller = new AbortController() + const scanPromise = mux.request( + 'fs.listFiles', + { rootPath: '/big/workspace' }, + { signal: controller.signal } + ) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) + + controller.abort() + await expect(scanPromise).rejects.toThrow('was cancelled') + await flushPipe() + + // The rpc.cancel notification must have aborted the scan on the relay. + expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) + }) + + it('a scan for a different workspace supersedes the previous scan', async () => { + const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + // Attach the rejection handler up front: the error response arrives on a + // macrotask inside flushPipe, before any later `await expect` could. + const firstOutcome = first.then( + () => null, + (err: Error) => err + ) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + const second = mux.request('fs.listFiles', { rootPath: '/workspace/b' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(2) + + // The stale scan is aborted and its request fails fast — it does not run + // to completion behind the new one and does not hit the 30s timeout. + expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) + const firstError = await firstOutcome + expect(firstError?.message).toContain(LIST_FILES_SUPERSEDED_MESSAGE) + + fakeListFiles.scans[1].resolve(['b.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('identical concurrent requests coalesce into a single scan', async () => { + const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + await flushPipe() + + expect(fakeListFiles.scans).toHaveLength(1) + fakeListFiles.scans[0].resolve(['shared.ts']) + await expect(first).resolves.toEqual(['shared.ts']) + await expect(second).resolves.toEqual(['shared.ts']) + }) + + it('cancelling one coalesced requester keeps the scan alive for the other', async () => { + const controller = new AbortController() + const first = mux.request( + 'fs.listFiles', + { rootPath: '/workspace/a' }, + { signal: controller.signal } + ) + const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + controller.abort() + await expect(first).rejects.toThrow('was cancelled') + await flushPipe() + expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) + + fakeListFiles.scans[0].resolve(['still.ts']) + await expect(second).resolves.toEqual(['still.ts']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.test.ts b/src/relay/fs-list-files-scan-coordinator.test.ts new file mode 100644 index 00000000000..a142c120cdb --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { + LIST_FILES_SUPERSEDED_MESSAGE, + ListFilesScanCoordinator +} from './fs-list-files-scan-coordinator' +import { + FileListingCancelledError, + isFileListingCancellation +} from '../shared/file-listing-cancellation' + +type Deferred = { + promise: Promise + resolve: (files: string[]) => void + reject: (error: Error) => void +} + +function deferred(): Deferred { + let resolve!: (files: string[]) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** Scan runner that resolves/rejects on demand and rejects when aborted. */ +function controllableScan(): { + start: (signal: AbortSignal) => Promise + starts: AbortSignal[] + finish: (files: string[]) => void +} { + const starts: AbortSignal[] = [] + let current: Deferred | null = null + return { + starts, + start: (signal: AbortSignal) => { + starts.push(signal) + const scan = deferred() + current = scan + signal.addEventListener( + 'abort', + () => { + scan.reject( + signal.reason instanceof Error ? signal.reason : new FileListingCancelledError() + ) + }, + { once: true } + ) + return scan.promise + }, + finish: (files: string[]) => current?.resolve(files) + } +} + +describe('ListFilesScanCoordinator', () => { + it('coalesces same-key concurrent requests into one scan', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + + expect(scan.starts).toHaveLength(1) + scan.finish(['x.ts']) + await expect(first).resolves.toEqual(['x.ts']) + await expect(second).resolves.toEqual(['x.ts']) + }) + + it('supersedes a different-key scan: aborts the old one and rejects it fast', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scan.start }) + const second = coordinator.run({ clientId: 1, key: 'workspace-b', start: scan.start }) + + expect(scan.starts).toHaveLength(2) + expect(scan.starts[0].aborted).toBe(true) + await expect(first).rejects.toThrow(LIST_FILES_SUPERSEDED_MESSAGE) + await first.catch((err) => expect(isFileListingCancellation(err)).toBe(true)) + + expect(scan.starts[1].aborted).toBe(false) + scan.finish(['b.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('keeps scans from different clients independent', async () => { + const coordinator = new ListFilesScanCoordinator() + const scanA = controllableScan() + const scanB = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scanA.start }) + const second = coordinator.run({ clientId: 2, key: 'workspace-b', start: scanB.start }) + + expect(scanA.starts[0].aborted).toBe(false) + scanA.finish(['a.ts']) + scanB.finish(['b.ts']) + await expect(first).resolves.toEqual(['a.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('aborts the scan when its only requester cancels', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const requester = new AbortController() + + const result = coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: scan.start + }) + + requester.abort() + expect(scan.starts[0].aborted).toBe(true) + await expect(result).rejects.toSatisfy(isFileListingCancellation) + }) + + it('keeps a coalesced scan alive while another requester still waits', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const first = new AbortController() + const second = new AbortController() + + const firstResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: first.signal, + start: scan.start + }) + const secondResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: second.signal, + start: scan.start + }) + + first.abort() + expect(scan.starts[0].aborted).toBe(false) + // The aborting requester observes its own cancellation immediately, + // while the shared scan keeps running for the sibling. + await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) + + second.abort() + expect(scan.starts[0].aborted).toBe(true) + await expect(secondResult).rejects.toSatisfy(isFileListingCancellation) + }) + + it('rejects an aborted coalesced requester while the sibling still resolves', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const first = new AbortController() + + const firstResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: first.signal, + start: scan.start + }) + const secondResult = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + + first.abort() + scan.finish(['kept.ts']) + + await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) + await expect(secondResult).resolves.toEqual(['kept.ts']) + }) + + it('rejects immediately when the requester is already cancelled', async () => { + const coordinator = new ListFilesScanCoordinator() + const start = vi.fn() + const requester = new AbortController() + requester.abort() + + await expect( + coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: start as unknown as (signal: AbortSignal) => Promise + }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(start).not.toHaveBeenCalled() + }) + + it('starts a fresh scan for the same key after the previous one settled', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + scan.finish(['old.ts']) + await expect(first).resolves.toEqual(['old.ts']) + + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + expect(scan.starts).toHaveLength(2) + scan.finish(['new.ts']) + await expect(second).resolves.toEqual(['new.ts']) + }) + + it('does not join a scan that is already aborted; starts a replacement', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const requester = new AbortController() + + const first = coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: scan.start + }) + requester.abort() + await expect(first).rejects.toSatisfy(isFileListingCancellation) + + // The aborted entry may still be in the map until its promise settles; + // a new same-key request must get a live scan, not the dead one. + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + expect(scan.starts).toHaveLength(2) + expect(scan.starts[1].aborted).toBe(false) + scan.finish(['fresh.ts']) + await expect(second).resolves.toEqual(['fresh.ts']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.ts b/src/relay/fs-list-files-scan-coordinator.ts new file mode 100644 index 00000000000..6e6e30c5f0d --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.ts @@ -0,0 +1,104 @@ +/** + * Single-flight coordinator for fs.listFiles full-tree scans (#7721). + * + * Why: rapid workspace switching used to stack N concurrent full-tree scans + * on the single-threaded relay and its one SSH channel, starving small + * interactive fs.readDir/fs.stat requests past their 30s timeout. This + * coordinator guarantees at most one scan in flight per client: + * - a request for the same root/excludes joins the in-flight scan + * (Quick Open + file-explorer filter share one scan), + * - a request for a different root supersedes it — the old scan is aborted + * and its request fails fast instead of running to completion (this also + * protects against older Orca clients that never send rpc.cancel), + * - when every joined requester cancels (rpc.cancel / client detach), the + * scan is aborted so abandoned work stops immediately. + */ +import { + FileListingCancelledError, + fileListingCancellationError +} from '../shared/file-listing-cancellation' + +export const LIST_FILES_SUPERSEDED_MESSAGE = 'File listing superseded by a newer request' + +type ScanEntry = { + key: string + controller: AbortController + promise: Promise + attachedCount: number +} + +export class ListFilesScanCoordinator { + private readonly scansByClient = new Map() + + run(opts: { + clientId: number + key: string + signal?: AbortSignal + start: (signal: AbortSignal) => Promise + }): Promise { + const { clientId, key, signal, start } = opts + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } + + const existing = this.scansByClient.get(clientId) + if (existing && existing.key === key && !existing.controller.signal.aborted) { + return this.attach(existing, signal) + } + if (existing) { + existing.controller.abort(new FileListingCancelledError(LIST_FILES_SUPERSEDED_MESSAGE)) + } + + const controller = new AbortController() + const entry: ScanEntry = { + key, + controller, + promise: Promise.resolve([]), + attachedCount: 0 + } + this.scansByClient.set(clientId, entry) + entry.promise = start(controller.signal).finally(() => { + if (this.scansByClient.get(clientId) === entry) { + this.scansByClient.delete(clientId) + } + }) + return this.attach(entry, signal) + } + + private attach(entry: ScanEntry, signal?: AbortSignal): Promise { + entry.attachedCount++ + if (!signal) { + return entry.promise + } + // Why: an aborting requester must see its own cancellation even though + // the shared scan keeps running (and may later resolve) for coalesced + // siblings — so each attachment gets its own promise. + return new Promise((resolve, reject) => { + let settled = false + const settle = (complete: () => void): void => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', onAbort) + complete() + } + const onAbort = (): void => + settle(() => { + entry.attachedCount-- + const cancellation = fileListingCancellationError(signal) + // Why: only stop the shared scan when nobody is left waiting on it — + // one requester cancelling must not break a coalesced sibling. + if (entry.attachedCount <= 0) { + entry.controller.abort(cancellation) + } + reject(cancellation) + }) + signal.addEventListener('abort', onAbort, { once: true }) + entry.promise.then( + (files) => settle(() => resolve(files)), + (error) => settle(() => reject(error)) + ) + }) + } +} diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index ff76e430553..4a2305e06e3 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -10,9 +10,11 @@ import type { AppState } from '@/store/types' import { useRuntimeFileListForWorktree, type RuntimeFileListState } from './quick-open-file-list' const listRuntimeFilesMock = vi.hoisted(() => vi.fn()) +const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/runtime-file-client', () => ({ - listRuntimeFiles: listRuntimeFilesMock + listRuntimeFiles: listRuntimeFilesMock, + cancelRuntimeFileList: cancelRuntimeFileListMock })) const initialAppState = useAppStore.getInitialState() @@ -104,6 +106,7 @@ async function renderProbe(args: { beforeEach(() => { useAppStore.setState(initialAppState, true) listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json']) + cancelRuntimeFileListMock.mockReset() }) afterEach(async () => { @@ -155,9 +158,37 @@ describe('useRuntimeFileListForWorktree', () => { }), { rootPath: '/srv/platform', - excludePaths: undefined + excludePaths: undefined, + requestToken: expect.any(String) } ) expect(states.at(-1)?.files).toEqual(['packages/app/package.json']) }) + + it('cancels the in-flight scan with the same request token on unmount (#7721)', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })], + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + + const root = await renderProbe({ + enabled: true, + onState: () => {}, + worktreeId: workspaceKey + }) + await waitForListRuntimeFilesCall() + + const [listContext, listRequest] = listRuntimeFilesMock.mock.calls[0] + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + + await act(async () => { + root.unmount() + }) + + expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) + }) }) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 9a7ffe8d596..13e3738e226 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { Worktree } from '../../../shared/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { getConnectionIdFromState } from '@/lib/connection-context' +import { createBrowserUuid } from '@/lib/browser-uuid' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { listRuntimeFiles } from '@/runtime/runtime-file-client' +import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { useWorktreesForRepo } from '@/store/selectors' @@ -152,21 +153,21 @@ export function useRuntimeFileListForWorktree({ setLoading(true) const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined + const requestToken = createBrowserUuid() + const requestContext = { + // Why: Quick Open lists files for the selected workspace. It must + // follow that workspace's owner host, not the globally focused host. + settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), + worktreeId, + worktreePath, + connectionId + } - void listRuntimeFiles( - { - // Why: Quick Open lists files for the selected workspace. It must - // follow that workspace's owner host, not the globally focused host. - settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), - worktreeId, - worktreePath, - connectionId - }, - { - rootPath: worktreePath, - excludePaths - } - ) + void listRuntimeFiles(requestContext, { + rootPath: worktreePath, + excludePaths, + requestToken + }) .then((result) => { if (!cancelled) { setFiles(result) @@ -186,6 +187,11 @@ export function useRuntimeFileListForWorktree({ return () => { cancelled = true + // Why #7721: switching workspaces (or closing the palette) must abort + // the previous full-tree scan host- and relay-side. Over SSH, abandoned + // scans otherwise stack up and starve fs.readDir/fs.stat past their + // 30s timeout ("Could not load files for this workspace"). + cancelRuntimeFileList(requestContext, requestToken) } }, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath]) diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index f1ce3f69e9c..90366acbfa4 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -3,6 +3,7 @@ preload API plus remote fallbacks; keeping route coverage together makes local versus environment behavior easy to audit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelRuntimeFileList, copyRuntimePath, createRuntimePath, deleteRuntimePath, @@ -37,6 +38,8 @@ const fsDeletePath = vi.fn() const fsStat = vi.fn() const fsPathExists = vi.fn() const fsSearch = vi.fn() +const fsListFiles = vi.fn() +const fsCancelListFiles = vi.fn() const fsDownloadFile = vi.fn() const fsSaveDownloadedFile = vi.fn() const fsStartDownloadedFile = vi.fn() @@ -63,6 +66,9 @@ beforeEach(() => { fsStat.mockReset() fsPathExists.mockReset() fsSearch.mockReset() + fsListFiles.mockReset() + fsCancelListFiles.mockReset() + fsCancelListFiles.mockResolvedValue(undefined) fsDownloadFile.mockReset() fsSaveDownloadedFile.mockReset() fsStartDownloadedFile.mockReset() @@ -102,6 +108,8 @@ beforeEach(() => { stat: fsStat, pathExists: fsPathExists, search: fsSearch, + listFiles: fsListFiles, + cancelListFiles: fsCancelListFiles, downloadFile: fsDownloadFile, saveDownloadedFile: fsSaveDownloadedFile, startDownloadedFile: fsStartDownloadedFile, @@ -1357,6 +1365,56 @@ describe('runtime file client', () => { }) }) + it('passes the cancellation token through the IPC file listing path (#7721)', async () => { + fsListFiles.mockResolvedValue(['src/index.ts']) + + await expect( + listRuntimeFiles( + { + settings: {}, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + { + rootPath: '/remote/repo', + requestToken: 'token-1' + } + ) + ).resolves.toEqual(['src/index.ts']) + + expect(fsListFiles).toHaveBeenCalledWith({ + rootPath: '/remote/repo', + connectionId: 'ssh-1', + excludePaths: undefined, + requestToken: 'token-1' + }) + }) + + it('cancelRuntimeFileList aborts the IPC listing but not environment listings (#7721)', () => { + cancelRuntimeFileList( + { + settings: {}, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + 'token-1' + ) + expect(fsCancelListFiles).toHaveBeenCalledWith({ requestToken: 'token-1' }) + + fsCancelListFiles.mockClear() + cancelRuntimeFileList( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + 'token-2' + ) + expect(fsCancelListFiles).not.toHaveBeenCalled() + }) + it('routes markdown document listing and stat through the selected runtime', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index e9161f754c1..eddda11a6c1 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -654,14 +654,15 @@ export async function searchRuntimeFiles( export async function listRuntimeFiles( context: RuntimeFileOperationArgs, - args: { rootPath: string; excludePaths?: string[] } + args: { rootPath: string; excludePaths?: string[]; requestToken?: string } ): Promise { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listFiles({ rootPath: args.rootPath, connectionId: context.connectionId, - excludePaths: args.excludePaths + excludePaths: args.excludePaths, + requestToken: args.requestToken }) } return callRuntimeRpc( @@ -675,6 +676,24 @@ export async function listRuntimeFiles( ) } +/** + * Best-effort abort of an in-flight listRuntimeFiles call (#7721). Switching + * workspaces must stop the previous workspace's full-tree scan — over SSH an + * abandoned scan keeps loading the relay and starves fs.readDir/fs.stat. + */ +export function cancelRuntimeFileList( + context: RuntimeFileOperationArgs, + requestToken: string +): void { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + void window.api.fs.cancelListFiles({ requestToken }).catch(() => { + /* cancellation is advisory; the request path has its own timeouts */ + }) + } + // Environment runtimes bound files.listAll with their own RPC timeout. +} + export async function listRuntimeMarkdownDocuments( context: RuntimeFileOperationArgs, rootPath: string diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index af231d8062c..7e181cdbb2b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1509,6 +1509,10 @@ function createFileApi(): NonNullable['fs']> { ) return result.files.map((entry) => entry.relativePath) }, + cancelListFiles: async () => { + // Why: the paired-web path lists files over runtime RPC with its own + // request timeout; there is no host-side scan to abort from here. + }, search: async (args) => { const file = await resolveRuntimeFilePath(args.rootPath) return callRuntimeResult('files.search', { diff --git a/src/shared/file-listing-cancellation.ts b/src/shared/file-listing-cancellation.ts new file mode 100644 index 00000000000..3296b80c925 --- /dev/null +++ b/src/shared/file-listing-cancellation.ts @@ -0,0 +1,38 @@ +/** + * Cancellation identity for workspace file-list scans (fs.listFiles). + * + * Why a dedicated error class: the readdir fallback wraps budget errors into + * "install rg" guidance and Quick Open surfaces load errors verbatim, so a + * cancelled scan must stay distinguishable from a genuine listing failure at + * every layer (relay rg/git/readdir, main-process local scan, renderer). + */ +export class FileListingCancelledError extends Error { + constructor(message = 'File listing cancelled') { + super(message) + this.name = 'FileListingCancelledError' + } +} + +/** + * Build the rejection for an aborted scan, preferring the abort reason so a + * superseded scan reports "superseded" rather than a generic cancellation. + * Only FileListingCancelledError reasons pass through — a bare abort() sets + * signal.reason to a DOMException that must not leak past the classifier. + */ +export function fileListingCancellationError(signal?: AbortSignal): Error { + const reason = signal?.reason + if (reason instanceof FileListingCancelledError) { + return reason + } + return new FileListingCancelledError() +} + +export function isFileListingCancellation(error: unknown): boolean { + return error instanceof FileListingCancelledError +} + +export function throwIfFileListingCancelled(signal?: AbortSignal): void { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } +} diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 66029032a2c..cb73572bc34 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -24,6 +24,7 @@ import { listQuickOpenFilesWithReaddir, parseQuickOpenGitLsFilesEntry } from './quick-open-readdir-walk' +import { isFileListingCancellation } from './file-listing-cancellation' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -259,4 +260,35 @@ describe('quick-open readdir walk', () => { }) ).resolves.toEqual(['packages/app [one] space/src/main.ts']) }) + + it('stops the walk with a cancellation error when the signal aborts (#7721)', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/a.ts') + await writeRel(root, 'src/b.ts') + + const controller = new AbortController() + controller.abort() + + const rejection = listQuickOpenFilesWithReaddir(root, { signal: controller.signal }) + await expect(rejection).rejects.toSatisfy(isFileListingCancellation) + // Cancellation must never be mistaken for a budget error, which callers + // translate into "install rg" guidance. + await rejection.catch((err) => expect(isQuickOpenReaddirBudgetError(err)).toBe(false)) + }) + + it('stops nested-repo expansion when the signal aborts (#7721)', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/kept.ts') + + const controller = new AbortController() + controller.abort() + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: ['src/kept.ts'], + signal: controller.signal + }) + ).rejects.toSatisfy(isFileListingCancellation) + }) }) diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index 5d3a0a52f53..90ce3ea9fdf 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -1,5 +1,6 @@ import { lstat, readdir } from 'node:fs/promises' import { join, relative } from 'node:path' +import { throwIfFileListingCancelled } from './file-listing-cancellation' import { HIDDEN_DIR_BLOCKLIST, shouldExcludeQuickOpenRelPath, @@ -157,6 +158,7 @@ export async function listQuickOpenFilesWithReaddir( opts: { excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + signal?: AbortSignal } = {} ): Promise { const files: string[] = [] @@ -164,6 +166,9 @@ export async function listQuickOpenFilesWithReaddir( const excludePathPrefixes = opts.excludePathPrefixes ?? [] async function walk(dir: string): Promise { + // Why: an abandoned scan (workspace switch) must stop consuming IO and + // event-loop time on the single-threaded relay, not just run to budget. + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) let entries @@ -176,6 +181,7 @@ export async function listQuickOpenFilesWithReaddir( } for (const entry of entries) { + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) const name = entry.name @@ -206,6 +212,7 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { gitPaths: Iterable excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + signal?: AbortSignal }): Promise { const files = new Set() const excludePathPrefixes = opts.excludePathPrefixes ?? [] @@ -224,6 +231,7 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { } for (const rawPath of opts.gitPaths) { + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) @@ -240,7 +248,8 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { // nested repo so the walk prunes excluded subtrees during traversal // instead of burning the shared budget and filtering them at the end. excludePathPrefixes: rebaseExcludePrefixesForNestedRepo(excludePathPrefixes, relPath), - budget + budget, + signal: opts.signal }) for (const nestedFile of nestedFiles) { addFinalPath(`${relPath}/${nestedFile}`) From 6c988356e227a6085c106da4cfccdd764a652be4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:08:27 -0700 Subject: [PATCH 065/152] =?UTF-8?q?fix:=20Remote=20Orca=20Server=20reconne?= =?UTF-8?q?ct=20liveness=20=E2=80=94=20recover=20mirrors,=20input=20routin?= =?UTF-8?q?g,=20and=20creates=20after=20tunnel=20drops=20(#7827)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 222 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 100 +++++++- src/main/runtime/rpc/methods/session-tabs.ts | 7 +- src/main/runtime/rpc/methods/terminal.ts | 18 +- .../terminal-multiplex-escape-tail.test.ts | 2 +- .../runtime/rpc/terminal-multiplex.test.ts | 94 +++++++- src/main/runtime/rpc/terminal-send.test.ts | 39 ++- .../remote-runtime-pty-transport.test.ts | 109 +++++++++ .../remote-runtime-pty-transport.ts | 48 +++- .../src/runtime/web-session-tabs-sync.test.ts | 41 ++++ .../src/runtime/web-session-tabs-sync.ts | 25 ++ src/shared/remote-runtime-client.test.ts | 36 ++- src/shared/remote-runtime-client.ts | 50 +++- .../remote-runtime-request-websocket.ts | 10 + ...-runtime-shared-control-connection.test.ts | 88 ++++++- ...emote-runtime-shared-control-connection.ts | 50 ++-- .../remote-runtime-shared-control-open.ts | 68 +++++- .../remote-runtime-shared-control-requests.ts | 16 +- ...remote-runtime-shared-control-stability.ts | 32 +++ .../remote-runtime-shared-control-state.ts | 10 +- ...te-runtime-shared-control-subscriptions.ts | 6 + .../remote-runtime-shared-control-types.ts | 4 + src/shared/remote-runtime-socket-liveness.ts | 75 ++++++ src/shared/runtime-subscription-replay.ts | 25 ++ 24 files changed, 1110 insertions(+), 65 deletions(-) create mode 100644 src/shared/remote-runtime-socket-liveness.ts create mode 100644 src/shared/runtime-subscription-replay.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5836b727795..ced9387d46f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -26897,4 +26897,226 @@ describe('OrcaRuntimeService', () => { expect(preDaemonProvider.shutdown).not.toHaveBeenCalled() }) }) + describe('stale terminal handle resolution (#7718)', () => { + function syncSingleTerminalGraph(runtime: OrcaRuntimeService, ptyId: string): void { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId, + paneTitle: 'Terminal 1' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Terminal 1', + isActive: true + } + ] + } + ] + }) + } + + // Why: session-tab mirrors travel as PTY handles, but client-created + // terminals (terminal.create) hold LEAF handles — the kind that can + // diverge from the pane's current PTY across reconnects. Issue one + // directly from the leaf record to pin the resolver semantics. + function issueLeafHandle(runtime: OrcaRuntimeService, ptyId: string): string { + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = Array.from(internals.leaves.values()).find( + (candidate) => candidate.ptyId === ptyId + ) + if (!leaf) { + throw new Error('expected leaf record') + } + return internals.issueHandle(leaf) + } + + it('errors with terminal_handle_stale instead of adopting a replacement PTY', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + syncSingleTerminalGraph(runtime, 'pty-a') + const handle = issueLeafHandle(runtime, 'pty-a') + + // Simulate the pane's PTY being replaced while the remote client still + // holds the old handle (the record missed the swap, e.g. across a + // renderer graph reload that preserved live PTY records). + const internals = runtime as unknown as { + leaves: Map + } + for (const leaf of internals.leaves.values()) { + if (leaf.ptyId === 'pty-a') { + leaf.ptyId = 'pty-b' + } + } + + // The unguarded resolver silently adopts the new PTY — the misroute. + expect(runtime.resolveLeafForHandle(handle)).toEqual({ ptyId: 'pty-b' }) + // The guarded resolver surfaces the staleness so clients can re-derive. + expect(() => runtime.resolveLiveLeafForHandle(handle)).toThrow('terminal_handle_stale') + }) + + it('lets a handle issued before its first PTY adopt that PTY without erroring', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + syncSingleTerminalGraph(runtime, 'pty-a') + const handle = issueLeafHandle(runtime, 'pty-a') + + // Simulate the mobile pre-spawn flow: the handle record predates the + // PTY (ptyId null); the pane's first PTY must still resolve. + const internals = runtime as unknown as { + handles: Map + } + const record = internals.handles.get(handle) + if (!record) { + throw new Error('expected handle record') + } + record.ptyId = null + + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' }) + }) + }) + + describe('mobile terminal create resilience (#7718)', () => { + it('cancels the surface wait without rolling back when the client connection dies', async () => { + vi.useFakeTimers() + try { + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const abort = new AbortController() + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-abort', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + signal: abort.signal + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The client's socket dies mid-wait. The create must settle right away + // (not run down the 10s surface timeout) and must NOT close the tab — + // the renderer spawned a real terminal the host user can see. + abort.abort() + await vi.advanceTimersByTimeAsync(0) + const outcome = await settled + + expect(outcome.ok).toBe(false) + if (outcome.ok === false) { + expect(outcome.error.message).toBe('client_disconnected') + } + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when its live shell has no registered pane key', async () => { + vi.useFakeTimers() + try { + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-stall', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // A live shell backs the tab, but its leaf id is not a terminal UUID + // so no pane key registers: the surface rescue can't publish it, and + // the surface itself never publishes (stalled renderer publication). + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-stall', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-stall', + paneTitle: null + } + ] + }) + + await vi.advanceTimersByTimeAsync(11_000) + const outcome = await settled + + // The create still fails (nothing to return), but the timeout must not + // kill the live terminal — that is the "tab dies after ~10s" symptom. + expect(outcome.ok).toBe(false) + if (outcome.ok === false) { + expect(outcome.error.message).toContain('Timed out') + } + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bb09d201a06..c245c9d8307 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1184,6 +1184,10 @@ const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 +function isClientDisconnectedError(error: unknown): boolean { + return error instanceof Error && error.message === 'client_disconnected' +} + function createTerminalRevealWarning(handle: string, error?: unknown): string { const reason = error instanceof Error && error.message.trim().length > 0 @@ -6239,6 +6243,32 @@ export class OrcaRuntimeService { return { ptyId: leaf.ptyId } } + // Why: remote clients hold handles across transport reconnects. A handle + // minted for a concrete PTY must never silently adopt a different PTY that + // later occupies the same pane — that misroutes keystrokes (#7718). Handles + // still awaiting their first PTY (ptyId null) may adopt it, which preserves + // the mobile pre-spawn subscribe flow. + resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + if ( + record.ptyId !== null && + (leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) + ) { + throw new Error('terminal_handle_stale') + } + return { ptyId: leaf.ptyId } + } + async resolveTerminalCwd(handle: string): Promise { const ptyId = this.resolveLeafForHandle(handle)?.ptyId if (!ptyId) { @@ -16263,6 +16293,7 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { const mutationId = opts.clientMutationId @@ -16304,6 +16335,7 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { this.assertGraphReady() @@ -16343,8 +16375,16 @@ export class OrcaRuntimeService { const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { ipcMain.removeListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) reject(new Error('Terminal creation timed out')) }, 10_000) + // Why: a dead client connection cancels the wait; the renderer tab (and + // its shell) stays alive for the host and mirrors on reconnect (#7718). + const onAbort = (): void => { + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('client_disconnected')) + } const handler = ( event: Electron.IpcMainEvent, @@ -16355,12 +16395,14 @@ export class OrcaRuntimeService { } clearTimeout(timer) ipcMain.removeListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) if (r.error) { reject(new Error(r.error)) } else { resolve({ tabId: r.tabId!, title: r.title ?? '' }) } } + opts.signal?.addEventListener('abort', onAbort, { once: true }) ipcMain.on('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, @@ -16398,18 +16440,25 @@ export class OrcaRuntimeService { // wait resolves without depending on a graph sync. this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { - timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS + timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, + signal: opts.signal }) if (this.isReadyMobileTerminalSurface(surface)) { return surface } const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, - requireReady: true + requireReady: true, + signal: opts.signal }).catch(() => null) if (readySurface) { return readySurface } + if (opts.signal?.aborted) { + // Why: nobody is waiting for this create anymore; do not materialize + // or roll back — the renderer's own publication settles the tab. + throw new Error('client_disconnected') + } const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) if (!pendingSurface) { throw new Error('Timed out waiting for terminal surface after creation') @@ -16443,6 +16492,16 @@ export class OrcaRuntimeService { return rescued } } + // Why: don't roll back when (a) the client connection died — the wait + // was cancelled, not the spawn — or (b) a live shell already backs the + // tab (its pane key may simply not be registered yet). Killing a real + // terminal the host user can see is the "tab dies after ~10s" bug (#7718). + if ( + isClientDisconnectedError(error) || + this.hasLiveShellForRendererTab(worktreeId, reply.tabId) + ) { + throw error + } // Why: the renderer created the tab but no live PTY backs it (true PTY // spawn/handle failure). Roll the half-created tab back via the renderer // close path so it can't linger as a ghost in mobile snapshots, then @@ -16662,33 +16721,44 @@ export class OrcaRuntimeService { private waitForMobileTerminalSurface( worktreeId: string, parentTabId: string, - options: { timeoutMs?: number; requireReady?: boolean } = {} + options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {} ): Promise { const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (existing) { return Promise.resolve(existing) } + if (options.signal?.aborted) { + return Promise.reject(new Error('client_disconnected')) + } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const cleanup = (): void => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } + } + const timer = setTimeout(() => { + cleanup() reject(new Error('Timed out waiting for terminal surface after creation')) }, timeoutMs) + // Why: a dead client connection cancels the wait immediately instead of + // running down the timeout and triggering rollback (#7718). + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) const check = (): void => { const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (!next) { return } - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } + cleanup() resolve(next) } this.graphSyncCallbacks.push(check) @@ -16778,6 +16848,18 @@ export class OrcaRuntimeService { return null } + // Why: rollback guard, looser than findLiveRegisteredPtyForRendererTab — a + // shell whose pane key hasn't registered yet can't be surface-rescued, but + // it is still a real terminal the create timeout must not kill (#7718). + private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean { + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) { + return true + } + } + return false + } + private isReadyMobileTerminalSurface( surface: RuntimeMobileSessionCreateTerminalResult | null ): boolean { diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index f2bf1641bda..d27d771f321 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -41,7 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime }) => + handler: async (params, { runtime, signal }) => runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, @@ -54,7 +54,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), activate: params.activate, - clientMutationId: params.clientMutationId + clientMutationId: params.clientMutationId, + // Why: a dead client connection must cancel the surface wait instead + // of running down the timeout and rolling back a live tab (#7718). + signal }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4ebe781495f..a750438e03e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -904,7 +904,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSend, handler: async (params, { runtime }) => { await assertTerminalSendTextWithinLimit(params.text) - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale (clients recover by re-deriving the handle) + // instead of evaluating driver/lock state against the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { @@ -1436,8 +1439,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const request = parsed.data detachStream(request.streamId, false) - let leaf = runtime.resolveLeafForHandle(request.terminal) const isMobile = request.client?.type === 'mobile' + let leaf: { ptyId: string | null } | null + try { + // Why: guarded resolution — binding the output stream to whatever + // PTY now occupies a stale handle's pane silently mirrors the wrong + // terminal after a reconnect (#7718). terminal_handle_stale lets the + // client re-derive the handle from the current session snapshot. + leaf = runtime.resolveLiveLeafForHandle(request.terminal) + } catch { + sendStreamError(request.streamId, 'terminal_handle_stale') + emit({ type: 'end', streamId: request.streamId }) + return + } if (!leaf?.ptyId && isMobile) { try { const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal) diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index ab255856216..59e4d2d1b29 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -41,7 +41,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'user@host:~$ ', diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 5ceee818dbb..69e956aa155 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -41,7 +41,7 @@ describe('terminal multiplex RPC', () => { current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void } = {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', @@ -258,7 +258,7 @@ describe('terminal multiplex RPC', () => { | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -371,7 +371,7 @@ describe('terminal multiplex RPC', () => { current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void } = {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', @@ -477,7 +477,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi .fn() .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), @@ -573,7 +573,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -681,7 +681,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -781,7 +781,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -1305,7 +1305,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), waitForLeafPtyId: vi.fn( (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((_resolve, reject) => { @@ -1372,6 +1372,78 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => { + // Why: after a reconnect a client can resubscribe with a handle whose + // pane now hosts a different PTY. Binding the stream anyway would mirror + // (and type into) the wrong terminal (#7718); the client recovers from + // terminal_handle_stale by re-deriving the handle from the next snapshot. + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-stale-handle', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'stale-terminal', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .some((result) => result?.type === 'end' && result.streamId === 9) + ).toBe(true) + ) + const errorFrame = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Error) + expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('terminal_handle_stale') + // The stream must never have bound to any PTY. + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + + cleanups.get('terminal-multiplex:conn-stale-handle')?.() + await dispatchPromise + }) + it('bounds live output queued while a multiplex snapshot is loading', async () => { vi.useFakeTimers() try { @@ -1385,7 +1457,7 @@ describe('terminal multiplex RPC', () => { const dataListenerRef: { current?: (data: string) => void } = {} const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => @@ -1494,7 +1566,7 @@ describe('terminal multiplex RPC', () => { const dataListenerRef: { current?: (data: string) => void } = {} const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => @@ -1607,7 +1679,7 @@ describe('terminal multiplex RPC', () => { rows: number }) => void = () => {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 82e47100088..b778f814977 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -75,9 +75,34 @@ describe('terminal send RPC', () => { expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') }) + it('fails terminal.send with terminal_handle_stale for a stale handle instead of probing the wrong PTY', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'stale-terminal', + text: 'x', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response.ok).toBe(false) + if (response.ok) { + throw new Error('expected stale handle error') + } + expect(response.error.message).toContain('terminal_handle_stale') + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('drops desktop input while a mobile client owns the terminal floor', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), sendTerminal: vi.fn(), mobileTookFloor: vi.fn() @@ -109,7 +134,7 @@ describe('terminal send RPC', () => { it('accepts legacy clientless mobile input when the current driver is mobile', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), sendTerminal: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -198,7 +223,7 @@ describe('terminal send RPC', () => { vi.useFakeTimers() const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), sendTerminal: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -236,7 +261,7 @@ describe('terminal send RPC', () => { it('refuses guarded terminal sends when the agent needs permission', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -274,7 +299,7 @@ describe('terminal send RPC', () => { it('allows guarded terminal sends when the agent is sendable', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -316,7 +341,7 @@ describe('terminal send RPC', () => { it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn(), sendTerminal: vi.fn() @@ -350,7 +375,7 @@ describe('terminal send RPC', () => { it('rechecks the mobile floor lock immediately before guarded PTY writes', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi .fn() .mockReturnValueOnce({ kind: 'desktop' }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 148acb6c005..69805980928 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -215,6 +215,115 @@ describe('createRemoteRuntimePtyTransport', () => { }) }) + it('re-derives the host session handle after a transport close instead of resubscribing the stale one', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtySpawn = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtySpawn + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-1' }) + + // Why: while the tunnel was down the host re-minted this pane's handle; + // resubscribing the stale closure handle would bind the mirror to a + // different PTY (#7718). The transport must re-derive from the snapshot. + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'session.tabs.list' + ? { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Terminal', + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ] + } + } + : { ok: true, result: {} } + ) + const subscribeCallsBefore = runtimeSubscribe.mock.calls.length + + // The dedicated multiplex socket dies (liveness/close) → onTransportClose. + subscriptionCallbacks?.onClose?.() + + await vi.waitFor(() => + expect(runtimeSubscribe.mock.calls.length).toBeGreaterThan(subscribeCallsBefore) + ) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' }) + ) + expect(transport.getPtyId()).toContain('terminal-2') + expect(onPtySpawn).toHaveBeenCalledWith(expect.stringContaining('terminal-2')) + }) + + it('retires the mirror when the host no longer publishes the surface after a transport close', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'session.tabs.list' + ? { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + } + : { ok: true, result: {} } + ) + + subscriptionCallbacks?.onClose?.() + + // Why: no red xterm error — retire quietly and let the next session-tabs + // snapshot drive respawn/removal. + await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1')) + expect(transport.getPtyId()).toBeNull() + expect(onError).not.toHaveBeenCalled() + }) + it('does not close host-owned terminal handles attached from session snapshots', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 9caf6210475..e9a8f361f5b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -182,6 +182,16 @@ export function createRemoteRuntimePtyTransport( return null } + async function listHostSessionHandle(hostTabId: string): Promise { + if (!worktreeId) { + return null + } + const listed = await callRuntime('session.tabs.list', { + worktree: toRuntimeWorktreeSelector(worktreeId) + }) + return findReadyHostSessionHandle(listed, hostTabId) + } + async function attachHostSessionMirror( options: Parameters[0] ): Promise { @@ -280,7 +290,9 @@ export function createRemoteRuntimePtyTransport( } return true } catch (error) { - storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + // Why: stale-handle errors must retire the mirror (recoverable via the + // next snapshot) rather than dead-end in a red xterm banner (#7718). + handleRemoteTerminalError(error) return false } } @@ -298,7 +310,7 @@ export function createRemoteRuntimePtyTransport( text, client: { id: clientId, type: 'desktop' } }).catch((error) => { - storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + handleRemoteTerminalError(error) }) }) @@ -371,6 +383,31 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onError?.(message) } + // Why: after a transport drop the host may have re-minted this pane's + // handle (reconnect, epoch or PTY change). Re-derive it from the current + // session snapshot instead of resubscribing the stale closure value, which + // would mirror (and type into) whatever PTY now sits behind it (#7718). + async function resubscribeAfterTransportClose(previousHandle: string): Promise { + if (tabId && isWebTerminalSurfaceTabId(tabId)) { + const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId)) + if (destroyed || !connected || handle !== previousHandle) { + return + } + if (!nextHandle) { + // Why: the host no longer publishes this surface; retire quietly and + // let the next session-tabs snapshot drive respawn/removal. + retireRemoteTerminalId() + return + } + if (nextHandle !== previousHandle) { + handle = nextHandle + remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId) + onPtySpawn?.(remotePtyId) + } + } + await subscribeToHandle() + } + async function subscribeToHandle(): Promise { if (!handle) { return @@ -453,10 +490,9 @@ export function createRemoteRuntimePtyTransport( } resubscribing = true const resubscribeHandle = handle - const resubscribePtyId = remotePtyId - void subscribeToHandle() + void resubscribeAfterTransportClose(resubscribeHandle) .catch((error) => { - if (isCurrentRemoteTerminal(resubscribeHandle, resubscribePtyId)) { + if (!destroyed && connected && handle) { handleRemoteTerminalError(error) } }) @@ -659,7 +695,7 @@ export function createRemoteRuntimePtyTransport( text, client: { id: clientId, type: 'desktop' } }).catch((error) => { - storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + handleRemoteTerminalError(error) }) return true }, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index be97ef98b87..7090ac68bc1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -22,6 +22,7 @@ import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../s import type { OpenFile } from '../store/slices/editor' import { _getWebSessionTabsTrackingCountsForTest, + acceptReplayedWebSessionTabsSnapshot, applyFreshWebSessionTabsSnapshot, applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots, @@ -145,6 +146,46 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(sameEpochOlder, ENV)).toBe(false) }) + it('accepts a replayed same-epoch same-version snapshot after a transport reconnect', () => { + // Why: after a shared-control reconnect the server re-emits the current + // snapshot with an UNCHANGED epoch/version (the host did not restart). + // Without the replay reset the monotonic gate rejects it and the mirror + // stays frozen (#7718). + const snapshot = makeSnapshot([], { snapshotVersion: 5, activeTabType: null }) + + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + // Same frame again during normal operation: still rejected as stale. + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(false) + + acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + + // The replay reset re-primes tracking: ordering protection resumes for + // subsequent frames (an older same-epoch frame is still rejected). + const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false) + const newer = makeSnapshot([], { snapshotVersion: 6, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(newer, ENV)).toBe(true) + }) + + it('scopes the replay reset to the replayed environment and worktree', () => { + const snapshot = makeSnapshot([], { snapshotVersion: 5, activeTabType: null }) + const otherWorktree = makeSnapshot([], { + worktree: 'repo::/other-worktree', + snapshotVersion: 5, + activeTabType: null + }) + + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(otherWorktree, ENV)).toBe(true) + + acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree) + + // Only the replayed worktree re-applies; the other stays gated. + expect(shouldApplyWebSessionTabsSnapshot(otherWorktree, ENV)).toBe(false) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + }) + it('ignores remote snapshots for the local floating workspace', () => { const floatingTab: TerminalTab = { id: 'floating-tab-1', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8d0eba1361d..47942c67433 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -66,6 +66,7 @@ import { endWebRuntimeWakeTerminalRespawn, shouldSkipWebRuntimeWakeTerminalRespawn } from './web-runtime-wake-terminal-respawn' +import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' @@ -172,6 +173,18 @@ export function getLastKnownHostTerminalTabCount( ) } +// Why: a post-reconnect subscription replay re-emits the current snapshot with +// an unchanged epoch/version; dropping the freshness entry lets the monotonic +// gate accept that replay as authoritative instead of freezing the mirror +// (#7718). Normal-operation ordering protection is untouched — this only runs +// for responses the connection tagged as reconnect replays. +export function acceptReplayedWebSessionTabsSnapshot( + environmentId: string, + worktreeId: string +): void { + latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId)) +} + export function shouldApplyWebSessionTabsSnapshot( snapshot: RuntimeMobileSessionTabsResult, environmentId: string @@ -2500,7 +2513,13 @@ export function useWebSessionTabsSync(): void { return } const event = response.result as SessionTabsStreamEvent + const replayed = isRuntimeSubscriptionReplayResponse(response) if (event.type === 'snapshots') { + if (replayed) { + for (const snapshot of event.snapshots) { + acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) + } + } useAppStore.setState((state) => applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) ) @@ -2509,6 +2528,9 @@ export function useWebSessionTabsSync(): void { if (event.type !== 'snapshot' && event.type !== 'updated') { return } + if (replayed) { + acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) + } useAppStore.setState((state) => applyFreshWebSessionTabsSnapshot(state, event, environmentId) ) @@ -2587,6 +2609,9 @@ export function useWebSessionTabsSync(): void { if (event.type !== 'snapshot' && event.type !== 'updated') { return } + if (isRuntimeSubscriptionReplayResponse(response)) { + acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) + } const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId) const syncState = useAppStore.getState() const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index ccb09c15dc3..5febb2f2ddf 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -104,6 +104,37 @@ describe('subscribeRemoteRuntimeRequest', () => { } }) + it('closes a half-open subscription socket via client liveness so callers can resubscribe', async () => { + // Why: dedicated stream sockets (terminal.multiplex, browser.screencast) + // must not hang forever when a tunnel goes half-open — no close frame, no + // pongs, no data (#7718). Client liveness surfaces onError/onClose so the + // renderer's onTransportClose resubscribe path can run. + const server = await createSubscriptionServer({ disableAutoPong: true }) + const onResponse = vi.fn() + const onError = vi.fn() + const onClose = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.multiplex', + {}, + 1000, + { onResponse, onError, onClose }, + { pingIntervalMs: 50, livenessTimeoutMs: 200 } + ) + + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + await vi.waitFor( + () => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'remote_runtime_unavailable' }) + ), + { timeout: 5000 } + ) + expect(onClose).toHaveBeenCalledOnce() + subscription.close() + }) + it('closes established subscription sockets after terminal protocol errors', async () => { const offSpy = vi.spyOn(WebSocketClient.prototype, 'off') try { @@ -223,6 +254,9 @@ describe('sendRemoteRuntimeRequest', () => { async function createSubscriptionServer( options: { sendMismatchedResponseAfterSubscribe?: boolean + // Why: half-open simulation — the socket stays open but never answers + // protocol pings, like a wedged tunnel that swallows frames silently. + disableAutoPong?: boolean } = {} ): Promise<{ pairing: PairingOffer @@ -233,7 +267,7 @@ async function createSubscriptionServer( const nextBinary = new Promise((resolve) => { resolveBinary = resolve }) - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) wss.on('connection', (ws) => { diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 2f9c2f59bff..ecd9f1df790 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -24,6 +24,11 @@ import { // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. import { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { + startRemoteRuntimeSocketLiveness, + type RemoteRuntimeSocketLivenessMonitor, + type RemoteRuntimeSocketLivenessOptions +} from './remote-runtime-socket-liveness' export { RemoteRuntimeClientError } from './remote-runtime-client-error' @@ -354,7 +359,8 @@ export async function subscribeRemoteRuntimeRequest( method: string, params: unknown, timeoutMs: number, - callbacks: RemoteRuntimeSubscriptionCallbacks + callbacks: RemoteRuntimeSubscriptionCallbacks, + livenessOptions?: RemoteRuntimeSocketLivenessOptions ): Promise { return await new Promise((resolve, reject) => { const requestId = randomUUID() @@ -364,8 +370,11 @@ export async function subscribeRemoteRuntimeRequest( let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null + let liveness: RemoteRuntimeSocketLivenessMonitor | null = null const cleanupSocketListeners = (): WebSocket | null => { + liveness?.stop() + liveness = null const socket = ws if (!socket) { return null @@ -374,6 +383,8 @@ export async function subscribeRemoteRuntimeRequest( socket.off('error', onError) socket.off('close', onClose) socket.off('message', onMessage) + socket.off('pong', onLivenessSignal) + socket.off('ping', onLivenessSignal) ws = null // Why: startup failures detach Orca callbacks before closing the ws, // but ws can still emit a late transport error while close is in flight. @@ -485,6 +496,7 @@ export async function subscribeRemoteRuntimeRequest( } function onMessage(data: WebSocket.RawData, isBinary: boolean): void { + liveness?.noteActivity() if (isBinary) { handleBinaryFrame(new Uint8Array(data as Buffer)) return @@ -515,10 +527,46 @@ export async function subscribeRemoteRuntimeRequest( handleRpcFrame(plaintext) } + function onLivenessSignal(): void { + liveness?.noteActivity() + } + ws.once('open', onOpen) ws.once('error', onError) ws.on('close', onClose) ws.on('message', onMessage) + ws.on('pong', onLivenessSignal) + ws.on('ping', onLivenessSignal) + + // Why: dedicated stream sockets (terminal.multiplex, browser.screencast) + // ride the same tunnels as shared control; a half-open drop must surface + // as a close so the renderer's onTransportClose resubscribe path runs + // instead of freezing the stream forever (#7718/#7489). + const monitoredWs = ws + liveness = startRemoteRuntimeSocketLiveness({ + ping: () => { + if (monitoredWs.readyState === WebSocket.OPEN) { + monitoredWs.ping() + } + }, + onDead: () => { + // Why: fail() first so listeners detach before terminate's close event; + // otherwise the close handler would emit a second onClose to callers. + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime stopped responding; the stream connection was reset.' + ) + ) + try { + // Why: close() on a half-open socket can hang for the OS TCP timeout. + monitoredWs.terminate() + } catch { + // Best-effort terminate; the subscription is already settled. + } + }, + options: livenessOptions + }) function handleReadyFrame(frame: string): void { let ready: unknown diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts index 40b4e999df9..e5b0ad8212d 100644 --- a/src/shared/remote-runtime-request-websocket.ts +++ b/src/shared/remote-runtime-request-websocket.ts @@ -22,6 +22,10 @@ export type RemoteRuntimeWebSocketCallbacks = { onClose: (ws: WebSocket, code: number, reason: Buffer) => void onError: (ws: WebSocket, error: RemoteRuntimeClientError) => void onTextFrame: (ws: WebSocket, frame: string) => void + // Why: protocol-level pongs (and server heartbeat pings) are the liveness + // signal for detecting half-open tunnels that never deliver `close` (#7718). + onPong?: (ws: WebSocket) => void + onPing?: (ws: WebSocket) => void } export function openRemoteRuntimeWebSocket( @@ -64,6 +68,8 @@ export function openRemoteRuntimeWebSocket( } callbacks.onTextFrame(ws, data.toString()) } + const onPong = (): void => callbacks.onPong?.(ws) + const onPing = (): void => callbacks.onPing?.(ws) const cleanup = (): void => { if (cleanedUp) { return @@ -73,6 +79,8 @@ export function openRemoteRuntimeWebSocket( ws.off('error', onError) ws.off('close', onClose) ws.off('message', onMessage) + ws.off('pong', onPong) + ws.off('ping', onPing) // Why: a manually closed ws can still emit a late transport error; keep // that from becoming an unhandled EventEmitter error after detaching Orca. if (ws.readyState !== WebSocket.CLOSED) { @@ -84,6 +92,8 @@ export function openRemoteRuntimeWebSocket( ws.on('error', onError) ws.on('close', onClose) ws.on('message', onMessage) + ws.on('pong', onPong) + ws.on('ping', onPing) return { ok: true, socket: { ws, sharedKey, cleanup } } } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index e807a519bc8..250ec27bf40 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -13,6 +13,7 @@ import { import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' +import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' const TEST_PROJECT_PATH = path.join('tmp', 'project') @@ -406,6 +407,83 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) + it('detects a half-open socket via client liveness, reconnects, and tags the replayed response', async () => { + // Why: the server keeps the TCP connection open but stops answering — + // no close frame, no pongs (autoPong disabled), no responses. This is the + // half-open devtunnel scenario from #7718: edge-triggered reconnect never + // fires, so client liveness must terminate the socket itself. + const server = await createServer({ disableAutoPong: true }) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing, { + liveness: { pingIntervalMs: 50, livenessTimeoutMs: 200 } + }) + const onResponse = vi.fn() + const onClose = vi.fn() + + await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { + onResponse, + onError: vi.fn(), + onClose + }) + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + expect(isRuntimeSubscriptionReplayResponse(onResponse.mock.calls[0]?.[0])).toBe(false) + + // Liveness terminates the silent socket and the reconnect path replays + // the subscription on a fresh connection. + await vi.waitFor(() => expect(server.connectionCount()).toBeGreaterThanOrEqual(2), { + timeout: 5000 + }) + await vi.waitFor( + () => + expect( + server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') + .length + ).toBeGreaterThanOrEqual(2), + { timeout: 5000 } + ) + // The first response after the reconnect replay carries the replay tag so + // snapshot freshness gates can accept the re-emitted snapshot. + await vi.waitFor( + () => + expect( + onResponse.mock.calls.some(([response]) => isRuntimeSubscriptionReplayResponse(response)) + ).toBe(true), + { timeout: 5000 } + ) + expect(onClose).not.toHaveBeenCalled() + + connection.close() + }) + + it('tears down the socket when a request times out and reconnects active subscriptions', async () => { + const server = await createServer({ silentMethods: ['worktree.hang'] }) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + const onResponse = vi.fn() + const onClose = vi.fn() + + await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { + onResponse, + onError: vi.fn(), + onClose + }) + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + + // Why: mirrors RemoteRuntimeRequestConnection — a request the server never + // answered marks the socket as suspect and must not leave it 'ready'. + await expect(connection.request('worktree.hang', undefined, 50)).rejects.toThrow('Timed out') + + await vi.waitFor(() => expect(server.connectionCount()).toBe(2), { timeout: 5000 }) + await vi.waitFor( + () => + expect( + server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') + ).toHaveLength(2), + { timeout: 5000 } + ) + expect(onClose).not.toHaveBeenCalled() + + connection.close() + }) + it('rejects pending requests and records close diagnostics when the socket closes', async () => { const server = await createServer({ closeBeforeResponse: true }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) @@ -434,6 +512,10 @@ async function createServer( closeAfterFirstStreamingResponse?: boolean closeBeforeResponse?: boolean suppressReadyFrame?: boolean + // Why: half-open simulation — the socket stays open but never answers + // protocol pings, like a wedged tunnel that swallows frames silently. + disableAutoPong?: boolean + silentMethods?: string[] } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -441,7 +523,7 @@ async function createServer( const delayedResponses: (() => void)[] = [] let connectionCount = 0 let closedAfterFirstStreamingResponse = false - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) wss.on('connection', (ws) => { @@ -531,10 +613,14 @@ function handleRequest( sendUnknownResponseBeforeResponse?: boolean closeAfterStreamingResponse?: () => boolean closeBeforeResponse?: boolean + silentMethods?: string[] }, delayedResponses: (() => void)[] ): void { requests.push(request) + if (options.silentMethods?.includes(request.method)) { + return + } if (options.closeBeforeResponse) { ws.close(4001, 'test close') return diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 2ea957e9fdc..585d3478cba 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -12,13 +12,14 @@ import { } from './remote-runtime-shared-control-ready' import { scheduleSharedControlReconnectOrFinish } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' -import { scheduleSharedControlStableReset } from './remote-runtime-shared-control-stability' +import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' import { sendSharedControlRequest, sendSharedControlSubscription } from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' +import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' import type { @@ -37,7 +38,7 @@ export class RemoteRuntimeSharedControlConnection { private sharedKey: Uint8Array | null = null private socketCleanup: (() => void) | null = null private reconnectTimer: ReturnType | null = null - private readyStableTimer: ReturnType | null = null + private readonly readyStableReset: SharedControlReadyStableResetTimer private reconnectAttempt = 0 private intentionallyClosed = false private lastConnectedAt: number | null = null @@ -46,11 +47,20 @@ export class RemoteRuntimeSharedControlConnection { private readonly pendingRequests = new Map>() private readonly subscriptions = new Map>() private readonly readyWaiters: SharedControlReadyWaiter[] = [] + private everReady = false constructor( private readonly pairing: PairingOffer, - private readonly options: { environmentId?: string; reconnectStableResetMs?: number } = {} - ) {} + private readonly options: { + environmentId?: string + reconnectStableResetMs?: number + liveness?: RemoteRuntimeSocketLivenessOptions + } = {} + ) { + this.readyStableReset = new SharedControlReadyStableResetTimer( + options.reconnectStableResetMs ?? 30_000 + ) + } request( method: string, @@ -64,7 +74,10 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId, requestMethod, requestParams) => - this.sendRequest(requestId, requestMethod, requestParams) + this.sendRequest(requestId, requestMethod, requestParams), + // Why: a timed-out request marks the socket as suspect (#7718) — tear + // it down so reconnect+replay runs instead of keeping a zombie socket. + onTimeout: (error) => this.handleSocketClosed(error) }) } @@ -145,7 +158,11 @@ export class RemoteRuntimeSharedControlConnection { this.lastError = error.message this.handleSocketClosed(error) }, - onTextFrame: (frame) => this.handleTextFrame(frame) + onTextFrame: (frame) => this.handleTextFrame(frame), + liveness: { + options: this.options.liveness, + onDead: (error) => this.handleSocketClosed(error) + } }) if (!opened.ok) { this.handleSocketClosed(opened.error) @@ -205,8 +222,11 @@ export class RemoteRuntimeSharedControlConnection { private replaySubscriptions(): void { sharedControlSubscriptions.replaySharedControlSubscriptions({ subscriptions: this.subscriptions, - send: (subscription) => this.sendSubscription(subscription) + send: (subscription) => this.sendSubscription(subscription), + // Why: only reconnects tag replays; first connects stay on the gated path. + tagReplayedResponses: this.everReady }) + this.everReady = true } private closeSubscription(requestId: string): void { @@ -260,7 +280,7 @@ export class RemoteRuntimeSharedControlConnection { socketCleanup: cleanup, ws, error, - clearReadyStableTimer: () => this.clearReadyStableTimer() + clearReadyStableTimer: () => this.readyStableReset.clear() }) this.ws = null this.sharedKey = null @@ -292,24 +312,12 @@ export class RemoteRuntimeSharedControlConnection { } private scheduleReconnectAttemptReset(): void { - this.clearReadyStableTimer() - this.readyStableTimer = scheduleSharedControlStableReset({ - delayMs: this.options.reconnectStableResetMs ?? 30_000, + this.readyStableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, reset: () => { this.reconnectAttempt = 0 - }, - clearCurrent: () => { - this.readyStableTimer = null } }) } - - private clearReadyStableTimer(): void { - if (this.readyStableTimer) { - clearTimeout(this.readyStableTimer) - this.readyStableTimer = null - } - } } diff --git a/src/shared/remote-runtime-shared-control-open.ts b/src/shared/remote-runtime-shared-control-open.ts index 6381f58dd83..ddb3eb0cd78 100644 --- a/src/shared/remote-runtime-shared-control-open.ts +++ b/src/shared/remote-runtime-shared-control-open.ts @@ -7,6 +7,10 @@ import { type RemoteRuntimeWebSocket } from './remote-runtime-request-websocket' import { formatSharedControlCloseMessage } from './remote-runtime-shared-control-protocol' +import { + startRemoteRuntimeSocketLiveness, + type RemoteRuntimeSocketLivenessOptions +} from './remote-runtime-socket-liveness' export function openSharedControlSocket( pairing: PairingOffer, @@ -15,9 +19,17 @@ export function openSharedControlSocket( onClose: (close: { code: number; reason: string }, error: RemoteRuntimeClientError) => void onError: (error: RemoteRuntimeClientError) => void onTextFrame: (frame: string) => void + // Why: reconnect is edge-triggered on `close`, but a half-open tunnel + // never delivers one. Liveness pings and declares the socket dead when + // the server goes silent, so the close/reconnect path can run (#7718). + liveness?: { + options?: RemoteRuntimeSocketLivenessOptions + onDead: (error: RemoteRuntimeClientError) => void + } } ): { ok: true; socket: RemoteRuntimeWebSocket } | { ok: false; error: RemoteRuntimeClientError } { - return openRemoteRuntimeWebSocket(pairing, { + let noteActivity: () => void = () => {} + const opened = openRemoteRuntimeWebSocket(pairing, { onClose: (ws, code, reason) => { if (callbacks.getCurrentSocket() === ws) { callbacks.onClose( @@ -33,8 +45,62 @@ export function openSharedControlSocket( }, onTextFrame: (ws, frame) => { if (callbacks.getCurrentSocket() === ws) { + noteActivity() callbacks.onTextFrame(frame) } + }, + onPong: (ws) => { + if (callbacks.getCurrentSocket() === ws) { + noteActivity() + } + }, + onPing: (ws) => { + if (callbacks.getCurrentSocket() === ws) { + noteActivity() + } } }) + if (!opened.ok || !callbacks.liveness) { + return opened + } + + const { ws, sharedKey, cleanup } = opened.socket + const liveness = callbacks.liveness + const monitor = startRemoteRuntimeSocketLiveness({ + ping: () => { + if (ws.readyState === 1) { + ws.ping() + } + }, + onDead: () => { + if (callbacks.getCurrentSocket() !== ws) { + return + } + try { + // Why: close() on a half-open socket can hang for the OS TCP timeout. + ws.terminate() + } catch { + // Best-effort terminate; the dead callback resets connection state. + } + liveness.onDead( + remoteRuntimeUnavailableError( + 'Remote Orca runtime stopped responding; resetting the control connection.' + ) + ) + }, + options: liveness.options + }) + noteActivity = monitor.noteActivity + + return { + ok: true, + socket: { + ws, + sharedKey, + cleanup: () => { + monitor.stop() + cleanup() + } + } + } } diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index bbd8900b9fe..5f8d0ca2b7c 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto' -import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' +import type { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { + remoteRuntimeTimeoutError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' @@ -12,6 +16,7 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void + onTimeout?: (error: RemoteRuntimeClientError) => void }): Promise> { const requestId = randomUUID() return new Promise>((resolve, reject) => { @@ -22,6 +27,15 @@ export function requestSharedControl(args: { } args.pendingRequests.delete(requestId) pending.reject(remoteRuntimeTimeoutError()) + // Why: a request the server never answered means the socket is suspect + // (half-open tunnels swallow frames silently); mirror + // RemoteRuntimeRequestConnection and hand the connection a teardown + // error so reconnect+replay runs instead of keeping a zombie socket. + args.onTimeout?.( + remoteRuntimeUnavailableError( + 'Remote Orca runtime did not answer in time; resetting the control connection.' + ) + ) }, args.timeoutMs) args.pendingRequests.set(requestId, { method: args.method, diff --git a/src/shared/remote-runtime-shared-control-stability.ts b/src/shared/remote-runtime-shared-control-stability.ts index 9e1024e69c8..867d5a6548a 100644 --- a/src/shared/remote-runtime-shared-control-stability.ts +++ b/src/shared/remote-runtime-shared-control-stability.ts @@ -21,3 +21,35 @@ export function scheduleSharedControlStableReset(args: { } return timer } + +// Why: owns the ready-stable timer's lifecycle so the connection only wires +// state accessors; scheduling always replaces any earlier pending timer. +export class SharedControlReadyStableResetTimer { + private timer: ReturnType | null = null + + constructor(private readonly delayMs: number) {} + + schedule(args: { + getState: () => SharedControlConnectionState + getSocket: () => WebSocket | null + reset: () => void + }): void { + this.clear() + this.timer = scheduleSharedControlStableReset({ + delayMs: this.delayMs, + getState: args.getState, + getSocket: args.getSocket, + reset: args.reset, + clearCurrent: () => { + this.timer = null + } + }) + } + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 9c22198f7df..72f139a4945 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -9,6 +9,7 @@ import type { SharedControlReadyWaiter } from './remote-runtime-shared-control-types' import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control-protocol' +import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' export function buildSharedControlDiagnostics(args: { state: SharedControlConnectionState @@ -149,7 +150,14 @@ export function handleSharedControlSubscriptionResponse( subscription.remoteSubscriptionId = subscriptionId } } - subscription.callbacks.onResponse(response) + let delivered = response + if (subscription.pendingReplayTag) { + subscription.pendingReplayTag = false + if (response.ok) { + delivered = tagRuntimeSubscriptionReplayResponse(response) + } + } + subscription.callbacks.onResponse(delivered) if (response.ok && isEndResult(response.result)) { finishSharedControlSubscription(subscriptions, subscription, false) } diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 466d1135471..d4122eac9e4 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -90,6 +90,9 @@ export function sendSharedControlCleanupRequest(args: { export function replaySharedControlSubscriptions(args: { subscriptions: Map> send: (subscription: SharedControlLogicalSubscription) => void + // Why: true only for a reconnect of a previously-ready connection; first + // connects deliver their initial snapshot through the normal gated path. + tagReplayedResponses?: boolean }): void { for (const subscription of args.subscriptions.values()) { if (subscription.closeAfterReady) { @@ -97,6 +100,9 @@ export function replaySharedControlSubscriptions(args: { } subscription.sent = false subscription.remoteSubscriptionId = null + if (args.tagReplayedResponses) { + subscription.pendingReplayTag = true + } args.send(subscription) } } diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 9f784faa8a6..4eb671ea3b3 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -30,6 +30,10 @@ export type SharedControlLogicalSubscription = { closed: boolean closeAfterReady: boolean remoteSubscriptionId: string | null + // Why: set while awaiting the first response after a reconnect replay; that + // response is the authoritative re-emitted snapshot and gets tagged so + // monotonic freshness gates don't drop it (#7718). + pendingReplayTag?: boolean } export type SharedControlReadyWaiter = { diff --git a/src/shared/remote-runtime-socket-liveness.ts b/src/shared/remote-runtime-socket-liveness.ts new file mode 100644 index 00000000000..2903a72c744 --- /dev/null +++ b/src/shared/remote-runtime-socket-liveness.ts @@ -0,0 +1,75 @@ +// Why: a half-open tunnel (devtunnel/NAT drop) never delivers a ws `close`, +// so edge-triggered reconnect logic on the client side never fires while the +// server has long since reaped its end (#7718/#7489). This monitor gives +// client sockets a level-based liveness check: ping on a cadence and declare +// the socket dead when no inbound traffic (frames, pings, or pongs) arrives +// within the window, so the existing close/reconnect path can run. + +// Why: pings ride the RFC 6455 control-frame layer, which every supported +// server (and the `ws` package it embeds) answers automatically — this stays +// backward compatible with old servers that predate client-side liveness. +export const REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS = 10_000 +// Why: just under two server heartbeat periods (15s), so a dead link is +// detected on a similar horizon to the server's own ping/terminate reaper. +export const REMOTE_RUNTIME_SOCKET_LIVENESS_TIMEOUT_MS = 25_000 + +export type RemoteRuntimeSocketLivenessOptions = { + pingIntervalMs?: number + livenessTimeoutMs?: number +} + +export type RemoteRuntimeSocketLivenessMonitor = { + noteActivity: () => void + stop: () => void +} + +export function startRemoteRuntimeSocketLiveness(args: { + ping: () => void + onDead: () => void + options?: RemoteRuntimeSocketLivenessOptions + now?: () => number +}): RemoteRuntimeSocketLivenessMonitor { + const now = args.now ?? Date.now + const pingIntervalMs = args.options?.pingIntervalMs ?? REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS + const livenessTimeoutMs = + args.options?.livenessTimeoutMs ?? REMOTE_RUNTIME_SOCKET_LIVENESS_TIMEOUT_MS + let lastActivityAt = now() + let stopped = false + + const timer = setInterval(() => { + if (stopped) { + return + } + if (now() - lastActivityAt > livenessTimeoutMs) { + stop() + args.onDead() + return + } + try { + args.ping() + } catch { + // Why: ping() can throw while a socket is mid-teardown; the liveness + // window (or the close handler) settles the socket's fate either way. + } + }, pingIntervalMs) + // Why: mobile typechecks shared code with DOM timer types where unref is absent. + const unrefable = timer as unknown as { unref?: () => void } + if (typeof unrefable.unref === 'function') { + unrefable.unref() + } + + function stop(): void { + if (stopped) { + return + } + stopped = true + clearInterval(timer) + } + + return { + noteActivity: () => { + lastActivityAt = now() + }, + stop + } +} diff --git a/src/shared/runtime-subscription-replay.ts b/src/shared/runtime-subscription-replay.ts new file mode 100644 index 00000000000..b290f1f2ce8 --- /dev/null +++ b/src/shared/runtime-subscription-replay.ts @@ -0,0 +1,25 @@ +// Why: after a shared-control reconnect, the client replays its logical +// subscriptions and the server re-emits each stream's current snapshot. When +// the host did not restart, that snapshot carries the same publicationEpoch/ +// snapshotVersion the client already applied, so monotonic freshness gates +// (e.g. shouldApplyWebSessionTabsSnapshot) would silently drop it and leave +// mirrors frozen (#7718). The connection tags the first response delivered +// after a replay so consumers can treat it as authoritative. The tag is added +// client-side after wire parsing — nothing changes on the protocol, so old +// servers are unaffected. + +const RUNTIME_SUBSCRIPTION_REPLAY_FLAG = '_replayedAfterReconnect' + +export function tagRuntimeSubscriptionReplayResponse( + response: TResponse +): TResponse { + return { ...response, [RUNTIME_SUBSCRIPTION_REPLAY_FLAG]: true } +} + +export function isRuntimeSubscriptionReplayResponse(response: unknown): boolean { + return ( + typeof response === 'object' && + response !== null && + (response as Record)[RUNTIME_SUBSCRIPTION_REPLAY_FLAG] === true + ) +} From ace196b08eeb8f7f2891f6a1a543d022b60ba69b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:39 -0700 Subject: [PATCH 066/152] fix(ssh): stop false "Remote connection dropped" after sleep/App Nap pauses (#7828) * fix(ssh): guard mux dead-link detector against sleep/App Nap timer pauses After system sleep or App Nap timer throttling, the first post-wake timeout-check tick saw pre-pause keepalives as >20s stale and killed a healthy link (false 'Connection timed out (no ack received)' -> dispose('connection_lost') -> reconnect overlay churn). Track the last tick time; when a tick gap far exceeds the interval, reset staleness tracking, probe with a fresh keepalive, and let the next full window make an honest liveness determination. A genuinely dead link is still detected within ~25s after wake. Also adds probeLiveness(timeoutMs): a keepalive round-trip primitive that resolves true on the first frame of any kind, used by the resume path to distinguish surviving links from dead ones. Refs #7773 Co-authored-by: Orca * fix(ssh): probe relay liveness on system resume instead of unconditional reconnect powerMonitor 'resume' previously called connectionManager.reconnect() for every active target, guaranteeing a teardown + reconnect overlay on every wake even when the connection survived sleep. Now each session's relay link is probed (keepalive round-trip, 5s timeout, one retry for slow post-wake network); only targets whose probe fails are reconnected. Dead-after-sleep connections still reconnect promptly. The 'suspend' grace-time handling is unchanged. Refs #7773 Co-authored-by: Orca * feat(relay): prefix relay.log diagnostic lines with ISO timestamps The remote relay.log had no timestamps, which blocked correlating reconnect flaps with user activity and sleep/wake windows while diagnosing #7773. Daemon-mode diagnostic lines now carry an ISO timestamp prefix (' [relay] ...', grep-stable). Connect-mode and orca-cli passthrough stderr is untouched since it goes back to the app/user terminal and is parsed (handshake-mismatch detection). The relay bundle is content-hashed at build time, so the versioned install picks up the new relay automatically on next deploy. Refs #7773 Co-authored-by: Orca * fix(ssh): re-check session identity before post-probe resume reconnect The resume probe can take ~10s; if the user disconnected the target or the session/connection was replaced during that window, reconnecting would resurrect an intentionally torn-down connection (CodeRabbit). Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/ssh.test.ts | 79 +++++++++++++++++++- src/main/ipc/ssh.ts | 54 ++++++++++--- src/main/ssh/ssh-channel-multiplexer.test.ts | 75 +++++++++++++++++++ src/main/ssh/ssh-channel-multiplexer.ts | 58 ++++++++++++++ src/relay/relay-diagnostic-log.test.ts | 21 ++++++ src/relay/relay-diagnostic-log.ts | 7 ++ src/relay/relay-handshake.ts | 13 ++-- src/relay/relay.ts | 49 ++++++------ 8 files changed, 310 insertions(+), 46 deletions(-) create mode 100644 src/relay/relay-diagnostic-log.test.ts create mode 100644 src/relay/relay-diagnostic-log.ts diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index cde245ee43f..0c5a51d9ae2 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -50,7 +50,8 @@ const { onRequest: vi.fn().mockReturnValue(() => {}), onDispose: vi.fn().mockReturnValue(() => {}), request: vi.fn().mockResolvedValue({}), - notify: vi.fn() + notify: vi.fn(), + probeLiveness: vi.fn().mockResolvedValue(false) }, mockPtyProvider: { onData: vi.fn(), @@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => { mockMux.isDisposed.mockReset().mockReturnValue(false) mockMux.onNotification.mockReset() mockMux.onDispose.mockReset().mockReturnValue(() => {}) + mockMux.probeLiveness.mockReset().mockResolvedValue(false) mockPtyProvider.onData.mockReset() mockPtyProvider.onExit.mockReset() mockPtyProvider.onReplay.mockReset() @@ -1401,7 +1403,7 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) - it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => { + it('reconnects on system resume when the relay liveness probe fails', async () => { const target: SshTarget = { id: 'ssh-1', label: 'Server', @@ -1419,6 +1421,7 @@ describe('SSH IPC handlers', () => { error: null, reconnectAttempt: 0 }) + mockMux.probeLiveness.mockResolvedValue(false) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) @@ -1427,7 +1430,77 @@ describe('SSH IPC handlers', () => { resumeListener() - expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1') + await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')) + // Why: a failed first probe gets one retry before teardown (slow post-wake network). + expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2) + }) + + it('skips reconnect on system resume when the relay link is still alive', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockMux.probeLiveness.mockResolvedValue(true) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] + expect(resumeListener).toBeTypeOf('function') + + resumeListener() + + await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1)) + // Let the async resume handler settle before asserting no teardown happened. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() + }) + + it('does not reconnect after resume when the target was disconnected during the probe', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockMux.probeLiveness.mockResolvedValue(false) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] + expect(resumeListener).toBeTypeOf('function') + + resumeListener() + // Why: the probe window is seconds long; a user disconnect during it must + // win — reconnecting afterwards would resurrect the torn-down target. + mockConnectionManager.getConnection.mockReturnValue(undefined) + + await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() }) it('extends active relay grace while the system is suspending', async () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 58391c4c0cd..8b5756bff38 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -442,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null) }) } +// Why: macOS can resume the process before the network stack is back up, so +// a failed first probe gets one retry before the link is declared dead (#7773). +const RESUME_PROBE_TIMEOUT_MS = 5_000 +const RESUME_PROBE_ATTEMPTS = 2 + +async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise { + const mux = session.getMux() + if (!mux || mux.isDisposed()) { + return false + } + for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { + if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) { + return true + } + } + return false +} + function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { @@ -450,19 +468,35 @@ function registerPowerMonitorReconnect(): void { } } const onResume = (): void => { - for (const targetId of activeSessions.keys()) { - const conn = connectionManager?.getConnection(targetId) + for (const [targetId, session] of activeSessions) { + const manager = connectionManager + const conn = manager?.getConnection(targetId) if (!conn) { continue } - const reconnect = connectionManager?.reconnect(targetId) - void reconnect?.catch((err) => { - console.warn( - `[ssh] Failed to reconnect ${targetId} after system resume: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) + void (async () => { + // Why: unconditional reconnect on every wake tore down live sessions + // and flashed the reconnect overlay (#7773). Only reconnect targets + // whose relay link actually died during sleep. + if (await isRelayLinkAliveAfterResume(session)) { + return + } + // Why: the probe can take ~10s. If the user disconnected or the + // session/connection was replaced meanwhile, reconnecting would + // resurrect a connection that was intentionally torn down. + if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) { + return + } + try { + await manager?.reconnect(targetId) + } catch (err) { + console.warn( + `[ssh] Failed to reconnect ${targetId} after system resume: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } + })() } } powerMonitor.on('suspend', onSuspend) diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 8c56ae145b0..10123d56a25 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -307,6 +307,81 @@ describe('SshChannelMultiplexer', () => { }) }) + describe('wake guard (timer pause across system sleep, #7773)', () => { + it('does not kill a healthy link on the first tick after a long timer pause', () => { + // Reach steady state with pending unacked keepalives (<5s old at pause). + vi.advanceTimersByTime(5_000) + expect(mux.isDisposed()).toBe(false) + + // Simulate sleep/App Nap: wall clock jumps far ahead with no ticks. + // Without the guard, the first post-wake tick sees lastReceivedAt and + // the pre-pause keepalive both >20s stale and disposes the mux. + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + const writesBefore = transport.written.length + vi.advanceTimersByTime(5_000) + + expect(mux.isDisposed()).toBe(false) + // The guard probes immediately with a fresh keepalive. + expect(transport.written.length).toBeGreaterThan(writesBefore) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + }) + + it('keeps the link alive after wake when frames resume', () => { + vi.advanceTimersByTime(5_000) + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + vi.advanceTimersByTime(5_000) // guard tick + + // The relay answers the post-wake probe; the link must stay up. + let seq = 1 + for (let i = 0; i < 8; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(seq++, 0)) + } + expect(mux.isDisposed()).toBe(false) + }) + + it('still detects a genuinely dead link within the next window after wake', () => { + vi.advanceTimersByTime(5_000) + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + vi.advanceTimersByTime(5_000) // guard tick: reset + probe, no kill + + expect(mux.isDisposed()).toBe(false) + // No frames arrive after the guard reset; the honest window expires. + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) + }) + + describe('probeLiveness', () => { + it('sends a keepalive and resolves true when any frame arrives', async () => { + const writesBefore = transport.written.length + const probe = mux.probeLiveness(5_000) + + expect(transport.written.length).toBe(writesBefore + 1) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + + transport.dataCallbacks[0](encodeKeepAliveFrame(1, 0)) + await expect(probe).resolves.toBe(true) + }) + + it('resolves false when no frame arrives before the timeout', async () => { + const probe = mux.probeLiveness(5_000) + vi.advanceTimersByTime(5_000) + await expect(probe).resolves.toBe(false) + }) + + it('resolves false when the mux is disposed while probing', async () => { + const probe = mux.probeLiveness(5_000) + mux.dispose() + await expect(probe).resolves.toBe(false) + }) + + it('resolves false immediately on a disposed mux', async () => { + mux.dispose() + await expect(mux.probeLiveness(5_000)).resolves.toBe(false) + }) + }) + describe('dispose', () => { it('rejects all pending requests on dispose', async () => { const promise = mux.request('pty.spawn') diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 09642387ee8..8cb0a24f302 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record) => voi export type RequestHandler = (params: Record) => Promise | unknown const REQUEST_TIMEOUT_MS = 30_000 +// Why: a tick gap far beyond the interval means the process was paused +// (system sleep, App Nap timer throttling) — not that the link is dead (#7773). +const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3 export class SshChannelMultiplexer { private decoder: FrameDecoder @@ -58,6 +61,10 @@ export class SshChannelMultiplexer { // Track the oldest unacked outgoing message timestamp private unackedTimestamps = new Map() + // Why: liveness probes (#7773) resolve on the first frame of any kind — + // a keepalive ack proves the relay round-trip without a full RPC. + private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = [] + constructor(transport: MultiplexerTransport) { this.transport = transport @@ -232,6 +239,31 @@ export class SshChannelMultiplexer { this.sendMessage(msg) } + /** + * Send a fresh keepalive and resolve true when any frame arrives before the + * timeout. Used on system resume to distinguish a link that survived sleep + * from a dead one before tearing the session down (#7773). + */ + probeLiveness(timeoutMs: number): Promise { + if (this.disposed) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const settle = (alive: boolean): void => { + clearTimeout(timer) + const idx = this.livenessProbeWaiters.indexOf(waiter) + if (idx !== -1) { + this.livenessProbeWaiters.splice(idx, 1) + } + resolve(alive) + } + const waiter = { succeed: () => settle(true), fail: () => settle(false) } + const timer = setTimeout(() => settle(false), timeoutMs) + this.livenessProbeWaiters.push(waiter) + this.sendKeepAlive() + }) + } + dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void { if (this.disposed) { return @@ -259,6 +291,10 @@ export class SshChannelMultiplexer { reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed' const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + for (const waiter of this.livenessProbeWaiters.splice(0)) { + waiter.fail() + } + for (const [id, pending] of this.pendingRequests) { pending.cleanup() const err = new Error(errorMessage) as Error & { code: string } @@ -322,6 +358,12 @@ export class SshChannelMultiplexer { } private handleFrame(frame: DecodedFrame): void { + // Why: any decoded frame proves the relay round-trip is alive; resolve + // pending resume probes before ordinary dispatch (#7773). + for (const waiter of this.livenessProbeWaiters.splice(0)) { + waiter.succeed() + } + // Update ack tracking if (frame.id > this.highestReceivedSeq) { this.highestReceivedSeq = frame.id @@ -453,12 +495,28 @@ export class SshChannelMultiplexer { } private startTimeoutCheck(): void { + let lastTickAt = Date.now() this.timeoutTimer = setInterval(() => { if (this.disposed) { return } const now = Date.now() + const sinceLastTick = now - lastTickAt + lastTickAt = now + // Why: after sleep/App Nap the pre-pause keepalive looks stale on the + // first post-wake tick, killing a healthy link (#7773). Reset staleness + // tracking, probe with a fresh keepalive, and let the NEXT full window + // make an honest liveness determination. + if (sinceLastTick > WAKE_GAP_MS) { + this.lastReceivedAt = now + for (const seq of this.unackedTimestamps.keys()) { + this.unackedTimestamps.set(seq, now) + } + this.sendKeepAlive() + return + } + const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS // Check oldest unacked message diff --git a/src/relay/relay-diagnostic-log.test.ts b/src/relay/relay-diagnostic-log.test.ts new file mode 100644 index 00000000000..38adbcb7669 --- /dev/null +++ b/src/relay/relay-diagnostic-log.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { relayLogLine } from './relay-diagnostic-log' + +describe('relayLogLine', () => { + it('prefixes each log line with an ISO timestamp', () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + relayLogLine('[relay] Grace started (stdin ended)') + + expect(writeSpy).toHaveBeenCalledTimes(1) + const line = writeSpy.mock.calls[0][0] as string + // Why: relay.log correlation depends on a grep-stable + // " " shape (#7773). + expect(line).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/ + ) + } finally { + writeSpy.mockRestore() + } + }) +}) diff --git a/src/relay/relay-diagnostic-log.ts b/src/relay/relay-diagnostic-log.ts new file mode 100644 index 00000000000..a42106101d9 --- /dev/null +++ b/src/relay/relay-diagnostic-log.ts @@ -0,0 +1,7 @@ +// Why: the relay daemon's stderr is redirected to relay.log on the remote +// host. Without timestamps, reconnect flaps in that log cannot be correlated +// with user activity or sleep/wake windows during diagnosis (#7773). Keep the +// format grep-stable: ISO timestamp, single space, then the original line. +export function relayLogLine(message: string): void { + process.stderr.write(`${new Date().toISOString()} ${message}\n`) +} diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index 6db263bcc92..fb64c57ec16 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -17,6 +17,7 @@ import { parseHandshakeMessage, type DecodedFrame } from './protocol' +import { relayLogLine } from './relay-diagnostic-log' // Why: a unique exit code reserved for the wire-level version-mismatch terminal // condition. The client (waitForSentinel + ssh.ts) maps this exit code to a @@ -132,22 +133,18 @@ function handleDaemonHandshakeFrame( try { msg = parseHandshakeMessage(frame.payload) } catch (err) { - process.stderr.write( - `[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n` - ) + relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`) sock.destroy() return false } if (msg.type !== 'orca-relay-handshake') { - process.stderr.write( - `[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n` - ) + relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`) sock.destroy() return false } if (msg.version !== launchVersion) { - process.stderr.write( - `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n` + relayLogLine( + `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket` ) try { sock.write( diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 99225227903..65f90e88628 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' +import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -358,13 +359,13 @@ async function main(): Promise { // would risk silent data corruption or zombie PTYs. We log for diagnostics // and then exit so the client can detect the disconnect and reconnect cleanly. process.on('uncaughtException', (err) => { - process.stderr.write(`[relay] Uncaught exception: ${err.message}\n${err.stack}\n`) + relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`) cleanupOwnedSocket() process.exit(1) }) process.on('unhandledRejection', (reason) => { - process.stderr.write(`[relay] Unhandled rejection: ${reason}\n`) + relayLogLine(`[relay] Unhandled rejection: ${reason}`) }) // Why: stdoutAlive tracks whether process.stdout is still writable. @@ -529,8 +530,8 @@ async function main(): Promise { try { await hookServer.start({ publishEndpoint: false }) } catch (err) { - process.stderr.write( - `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n` + relayLogLine( + `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}` ) } @@ -688,7 +689,7 @@ async function main(): Promise { function cancelGrace(reason: string): void { if (ptyHandler.graceTimerActive) { - process.stderr.write(`[relay] Grace canceled: ${reason}\n`) + relayLogLine(`[relay] Grace canceled: ${reason}`) } graceDeadlineAt = null graceReason = null @@ -704,8 +705,8 @@ async function main(): Promise { hasAcceptedSocketClient = true acceptedSocketConnections++ - process.stderr.write( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})\n` + relayLogLine( + `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` ) cancelGrace('socket client accepted') @@ -779,7 +780,7 @@ async function main(): Promise { if (clientId !== undefined) { dispatcher.detachClient(clientId) } - process.stderr.write(`[relay] Socket client closed (clients=${socketClients.size})\n`) + relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`) if (!stdoutAlive && socketClients.size === 0) { startGrace('socket client closed') } @@ -821,9 +822,9 @@ async function main(): Promise { ownsSocketPath = true ownedSocketIdentity = readSocketIdentity(sockPath) server.on('error', (err) => { - process.stderr.write(`[relay] Socket server error: ${err.message}\n`) + relayLogLine(`[relay] Socket server error: ${err.message}`) }) - process.stderr.write(`[relay] Socket server listening: ${sockPath}\n`) + relayLogLine(`[relay] Socket server listening: ${sockPath}`) resolve() } @@ -831,11 +832,11 @@ async function main(): Promise { removeStartupListeners() restoreUmask() if (err.code === 'EADDRINUSE') { - process.stderr.write( - `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.\n` + relayLogLine( + `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` ) } else { - process.stderr.write(`[relay] Socket server error before listen: ${err.message}\n`) + relayLogLine(`[relay] Socket server error before listen: ${err.message}`) } reject(err) } @@ -903,9 +904,7 @@ async function main(): Promise { failInitial(err) return } - process.stderr.write( - `[relay] Removed stale socket at ${sockPath} and retrying listen\n` - ) + relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`) removeStartupListeners() listenForStartupError(failInitial) }) @@ -958,11 +957,11 @@ async function main(): Promise { : graceTimeMs graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs graceReason = reason - process.stderr.write( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}\n` + relayLogLine( + `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` ) ptyHandler.startGraceTimer(() => { - process.stderr.write(`[relay] Grace expired (${reason}); shutting down\n`) + relayLogLine(`[relay] Grace expired (${reason}); shutting down`) shutdown() }, timeoutMs) } @@ -1006,8 +1005,8 @@ async function main(): Promise { } function shutdown(): void { - process.stderr.write( - `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}\n` + relayLogLine( + `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` ) graceDeadlineAt = null graceReason = null @@ -1034,10 +1033,10 @@ async function main(): Promise { // window — a reconnecting client can then bridge to the live relay via // --connect and reattach to the still-running PTY sessions. process.on('SIGHUP', () => { - process.stderr.write('[relay] Received SIGHUP (SSH session dropped), ignoring\n') + relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') }) process.on('exit', (code) => { - process.stderr.write(`[relay] Process exiting with code ${code}\n`) + relayLogLine(`[relay] Process exiting with code ${code}`) }) // Signal readiness to the client — the client watches for this exact @@ -1059,8 +1058,8 @@ function cleanupSocket(sockPath: string): void { } void main().catch((err) => { - process.stderr.write( - `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n` + relayLogLine( + `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` ) process.exit(1) }) From 2b3eba46cbf8abceadc5d91770f7e1064c08fbbc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:17:19 -0700 Subject: [PATCH 067/152] Per-environment SSH state so desktop clients get live SSH overlays for Remote Orca Server workspaces (STA-1468 desktop topology) (#7831) Co-authored-by: Orca --- .../components/terminal-pane/TerminalPane.tsx | 52 +++- .../TerminalSshReconnectOverlay.test.tsx | 68 +++++ .../TerminalSshReconnectOverlay.tsx | 44 ++- src/renderer/src/hooks/useIpcEvents.test.ts | 10 + src/renderer/src/hooks/useIpcEvents.ts | 49 +++- .../src/runtime/runtime-client-events.test.ts | 43 +++ .../src/runtime/runtime-client-events.ts | 16 +- .../runtime-environment-ssh-state.test.ts | 255 +++++++++++++++++ .../runtime/runtime-environment-ssh-state.ts | 165 +++++++++++ src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 2 + .../slices/runtime-environment-ssh.test.ts | 198 +++++++++++++ .../store/slices/runtime-environment-ssh.ts | 263 ++++++++++++++++++ .../src/store/slices/runtime-status.ts | 2 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/types.ts | 2 + 16 files changed, 1137 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/runtime/runtime-environment-ssh-state.test.ts create mode 100644 src/renderer/src/runtime/runtime-environment-ssh-state.ts create mode 100644 src/renderer/src/store/slices/runtime-environment-ssh.test.ts create mode 100644 src/renderer/src/store/slices/runtime-environment-ssh.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 90839885add..7bdb5d20392 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -24,7 +24,17 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from '@/store/slices/runtime-environment-ssh' +import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { @@ -306,33 +316,44 @@ export default function TerminalPane({ } return connectionId }) + // Which machine's SSH store this target belongs to: a remote Orca server's + // per-environment bucket, or null for this machine's local SSH maps. The + // explicit-owner resolver never lets a merely focused runtime make a + // local-owned workspace look remote. The paired web client mirrors its one + // host through the local maps instead. + const sshReconnectEnvironmentId = useAppStore((store) => + sshReconnectTargetId && !isPairedWebClientWindow() + ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) + : null + ) const sshReconnectStatus = useAppStore((store) => sshReconnectTargetId - ? (store.sshConnectionStates.get(sshReconnectTargetId)?.status ?? 'disconnected') + ? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId) : null ) const sshReconnectTargetLabel = useAppStore((store) => sshReconnectTargetId - ? (store.sshTargetLabels.get(sshReconnectTargetId) ?? - // Fall back to the removed target's last known label (ghost host) before - // the raw id, so a removed host shows its name instead of ssh--. - store.removedSshTargetLabels.get(sshReconnectTargetId) ?? - sshReconnectTargetId) + ? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId) : '' ) // Why: the target was removed entirely (a ghost) when it's no longer a known - // SSH target. Reconnecting to it can only fail ("SSH target not found"), so - // the overlay must offer to remove the workspace instead of Connect. - // Removal needs positive evidence — a removal tombstone label, or a - // successfully hydrated target list (even an empty one) that lacks the id. - // A client whose SSH state never hydrated (paired client on an older host) - // must not offer workspace removal off that ignorance. + // SSH target on its owning host. Reconnecting to it can only fail ("SSH + // target not found"), so the overlay must offer to remove the workspace + // instead of Connect. The selector requires positive removal evidence. const sshReconnectTargetRemoved = useAppStore((store) => sshReconnectTargetId - ? store.removedSshTargetLabels.has(sshReconnectTargetId) || - (store.sshTargetsHydrated && !store.sshTargetLabels.has(sshReconnectTargetId)) + ? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId) : false ) + useEffect(() => { + if (!sshReconnectEnvironmentId) { + return + } + // Why: an SSH-backed workspace can be mirrored before its owning + // environment's bucket ever hydrated (no-op once hydrated), and overlay + // state must come from fetched evidence, never from an empty default. + void hydrateRuntimeEnvironmentSshState(sshReconnectEnvironmentId).catch(() => {}) + }, [sshReconnectEnvironmentId]) useVisibleTerminalTabClaim({ isVisible, tabId }) @@ -2991,6 +3012,7 @@ export default function TerminalPane({ status={sshReconnectStatus} targetRemoved={sshReconnectTargetRemoved} worktreeId={worktreeId} + sshOwnerEnvironmentId={sshReconnectEnvironmentId} /> ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index 3b891b342fa..7f46a518153 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -17,6 +17,11 @@ const deleteFlowMocks = vi.hoisted(() => ({ runWorktreeDelete: vi.fn() })) +const environmentSshMocks = vi.hoisted(() => ({ + connectRuntimeEnvironmentSshTarget: vi.fn(), + resyncRuntimeEnvironmentSshTargets: vi.fn() +})) + vi.mock('sonner', () => ({ toast: { error: toastMocks.error @@ -27,6 +32,8 @@ vi.mock('../sidebar/delete-worktree-flow', () => ({ runWorktreeDelete: deleteFlowMocks.runWorktreeDelete })) +vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => fallback.replace('{{value0}}', values?.value0 ?? '') @@ -54,6 +61,8 @@ describe('TerminalSshReconnectOverlay', () => { useAppStore.setState(useAppStore.getInitialState(), true) toastMocks.error.mockReset() deleteFlowMocks.runWorktreeDelete.mockReset() + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset() + environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset() }) afterEach(() => { @@ -190,6 +199,65 @@ describe('TerminalSshReconnectOverlay', () => { expect(connect).not.toHaveBeenCalled() }) + it('routes Connect to the owning environment runtime RPC for a remote-owned workspace', async () => { + const connect = vi.fn().mockResolvedValue(undefined) + installSshConnect(connect) + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => + expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith( + 'env-1', + 'ssh-remote-1' + ) + ) + // The local ssh API must never see a remote host's target. + expect(connect).not.toHaveBeenCalled() + }) + + it('resyncs the owning environment (not the local store) after a failed remote connect', async () => { + const connect = vi.fn().mockResolvedValue(undefined) + const listTargets = vi.fn().mockResolvedValue([]) + installSshConnect(connect, { listTargets }) + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockRejectedValue( + new Error('SSH target "ssh-remote-dead" not found') + ) + environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined) + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => + expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-remote-dead" not found') + ) + await waitFor(() => + expect(environmentSshMocks.resyncRuntimeEnvironmentSshTargets).toHaveBeenCalledWith('env-1') + ) + // The failed-connect resync must not rewrite local target metadata. + expect(listTargets).not.toHaveBeenCalled() + expect(useAppStore.getState().sshTargetsHydrated).toBe(false) + }) + it('publishes the returned SSH state so deferred terminal reattach can resume', async () => { const connectedState: SshConnectionState = { targetId: 'ssh-target-1', diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index 8f3071de453..b3ee59833c5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -7,6 +7,10 @@ import { useAppStore } from '@/store' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' +import { + connectRuntimeEnvironmentSshTarget, + resyncRuntimeEnvironmentSshTargets +} from '@/runtime/runtime-environment-ssh-state' type TerminalSshReconnectOverlayProps = { targetId: string @@ -16,6 +20,10 @@ type TerminalSshReconnectOverlayProps = { // remove the workspace instead of a Connect button that can only fail. targetRemoved?: boolean worktreeId?: string + // Set when the SSH target belongs to a remote Orca server (runtime + // environment): Connect and the failed-connect resync then route to that + // environment's runtime RPC and bucket instead of the local ssh.* API. + sshOwnerEnvironmentId?: string | null } // Why: relay deployment/reconnect are host-driven transient states; the @@ -70,7 +78,8 @@ export function TerminalSshReconnectOverlay({ targetLabel, status, targetRemoved = false, - worktreeId + worktreeId, + sshOwnerEnvironmentId = null }: TerminalSshReconnectOverlayProps): React.JSX.Element { const [connecting, setConnecting] = useState(false) const mountedRef = useMountedRef() @@ -85,11 +94,16 @@ export function TerminalSshReconnectOverlay({ } setConnecting(true) try { - const connectState = await window.api.ssh.connect({ targetId }) - if (connectState) { - // Why: ssh.connect can resolve before the global state-change IPC lands; - // the waiting deferred PTY reattach path keys off this renderer store. - setSshConnectionState(targetId, connectState) + if (sshOwnerEnvironmentId) { + // Bucket state is written inside the helper, mirroring the local path. + await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId) + } else { + const connectState = await window.api.ssh.connect({ targetId }) + if (connectState) { + // Why: ssh.connect can resolve before the global state-change IPC lands; + // the waiting deferred PTY reattach path keys off this renderer store. + setSshConnectionState(targetId, connectState) + } } } catch (err) { toast.error( @@ -105,18 +119,22 @@ export function TerminalSshReconnectOverlay({ // overlay converges to the ghost/re-adopted state instead of offering // the same failing Connect forever (STA-1468). Apply the target list // first — a removed-labels failure must not discard it. - void (async () => { - const targets = await window.api.ssh.listTargets() - useAppStore.getState().setSshTargetsMetadata(targets) - const removedLabels = await window.api.ssh.listRemovedTargetLabels() - useAppStore.getState().setRemovedSshTargetLabels(removedLabels) - })().catch(() => {}) + if (sshOwnerEnvironmentId) { + void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {}) + } else { + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) + } } finally { if (mountedRef.current) { setConnecting(false) } } - }, [isConnecting, mountedRef, setSshConnectionState, targetId]) + }, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId]) return (
{ }) }) +describe('getNewlyDisconnectedRuntimeEnvironmentIds', () => { + it('returns only environments whose transport was just observed down', () => { + expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a', 'env-b'], ['env-a'])).toEqual([ + 'env-b' + ]) + expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual([]) + }) +}) + describe('getRuntimeProjectRefreshEnvironmentIds', () => { it('refreshes when an already-desired runtime becomes reachable', () => { expect( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ac867b3c3a3..e318b7120d2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -83,6 +83,10 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { + applyRuntimeEnvironmentSshStateChanged, + hydrateRuntimeEnvironmentSshState +} from '@/runtime/runtime-environment-ssh-state' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' @@ -770,6 +774,15 @@ export function getNewlyConnectedRuntimeEnvironmentIds( return [...new Set(next)].filter((environmentId) => !known.has(environmentId)) } +/** Ids in `previous` not in `next` — runtime environments whose transport was + * just observed down. Their mirrored SSH buckets get downgraded to unknown. */ +export function getNewlyDisconnectedRuntimeEnvironmentIds( + previous: readonly string[], + next: readonly string[] +): string[] { + return getNewlyConnectedRuntimeEnvironmentIds(next, previous) +} + export function getRuntimeProjectRefreshEnvironmentIds(args: { previousDesired: readonly string[] nextDesired: readonly string[] @@ -955,6 +968,13 @@ export function useIpcEvents(): void { const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ refresh: async (environmentId) => { + if (!isPairedWebClientWindow()) { + // Why: mirrored SSH-backed workspaces read the owning environment's + // SSH bucket; refresh it whenever the environment (re)connects so a + // pre-drop snapshot can't keep a reconnect overlay stale. The web + // client mirrors host SSH state through the global store instead. + void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) + } const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) await refreshRuntimeProjectWorktrees(repos) await useAppStore.getState().fetchWorktreeLineage() @@ -975,12 +995,14 @@ export function useIpcEvents(): void { return } if (event.type === 'sshStateChanged') { - // Why: only a paired web client routes its ssh.* API to the host that - // emitted this event, so only there can the store mirror host SSH state - // (STA-1468). Desktop clients own a local SSH surface a foreign - // runtime's targets would pollute, so they ignore it. + // Why: a paired web client mirrors host SSH state in the global store — + // its whole ssh.* API routes to that one host (STA-1468). A desktop + // client owns a local SSH surface those maps must keep describing, so a + // remote host's state goes into that environment's own bucket instead. if (isPairedWebClientWindow()) { handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state }) + } else { + applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state) } return } @@ -1008,7 +1030,17 @@ export function useIpcEvents(): void { const runtimeClientEventsSync = createRuntimeClientEventsSync({ getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, - subscribe: subscribeRuntimeClientEvents, + subscribe: (environmentId, onEvent, onError) => + subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => { + if (isPairedWebClientWindow()) { + return + } + // Why: sshStateChanged events during the transport gap are lost, so + // the pre-drop bucket may hold a stale "connected". Downgrade to + // unknown, then refetch the authoritative state. + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) + }), onEvent: handleRuntimeClientEvent }) @@ -1049,6 +1081,13 @@ export function useIpcEvents(): void { })) { runtimeProjectRefreshScheduler.request(environmentId) } + for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( + reachableRuntimeEnvironmentIds, + nextReachableEnvironmentIds + )) { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + } runtimeClientEventEnvironmentIds = nextEnvironmentIds runtimeClientEventEnvironmentKey = nextKey reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index 70acb400662..3d388888498 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -55,4 +55,47 @@ describe('subscribeRuntimeClientEvents', () => { subscription.unsubscribe() expect(unsubscribe).toHaveBeenCalledTimes(1) }) + + it('signals a replay-tagged response so event-derived state can resync after a reconnect', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + const onEvent = vi.fn() + const onReplayed = vi.fn() + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { subscribe } + } + }) + + await subscribeRuntimeClientEvents('env-1', onEvent, vi.fn(), onReplayed) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ + ok: true, + result: { type: 'ready', subscriptionId: 'sub-1' } + }) + expect(onReplayed).not.toHaveBeenCalled() + + capturedOnResponse({ + ok: true, + result: { type: 'ready', subscriptionId: 'sub-1' }, + _replayedAfterReconnect: true + }) + expect(onReplayed).toHaveBeenCalledTimes(1) + + // A replay-tagged event frame both signals and still delivers the event. + capturedOnResponse({ + ok: true, + result: { type: 'worktreesChanged', repoId: 'repo-1' }, + _replayedAfterReconnect: true + }) + expect(onReplayed).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' }) + }) }) diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 0d44ddcbf57..adbdc232433 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -3,6 +3,7 @@ import type { RuntimeClientEventStreamMessage } from '../../../shared/runtime-client-events' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' export type RuntimeClientEventSubscription = { unsubscribe: () => void @@ -11,7 +12,12 @@ export type RuntimeClientEventSubscription = { export async function subscribeRuntimeClientEvents( environmentId: string, onEvent: (event: RuntimeClientEvent) => void, - onError: (error: unknown) => void = console.warn + onError: (error: unknown) => void = console.warn, + // Why: client events emitted while the shared-control transport was down are + // lost, not queued. The replay tag on the first post-reconnect response is + // the renderer's only signal that mirrored event-derived state (e.g. the + // per-environment SSH bucket) may have missed transitions and must resync. + onReplayedAfterReconnect?: () => void ): Promise { const handle = await window.api.runtimeEnvironments.subscribe( { @@ -21,7 +27,7 @@ export async function subscribeRuntimeClientEvents( }, { onResponse: (response) => { - handleRuntimeClientEventResponse(response, onEvent, onError) + handleRuntimeClientEventResponse(response, onEvent, onError, onReplayedAfterReconnect) }, onError } @@ -32,12 +38,16 @@ export async function subscribeRuntimeClientEvents( function handleRuntimeClientEventResponse( response: RuntimeRpcResponse, onEvent: (event: RuntimeClientEvent) => void, - onError: (error: unknown) => void + onError: (error: unknown) => void, + onReplayedAfterReconnect?: () => void ): void { if (response.ok === false) { onError(response.error) return } + if (isRuntimeSubscriptionReplayResponse(response)) { + onReplayedAfterReconnect?.() + } const message = response.result as RuntimeClientEventStreamMessage if (message.type === 'ready' || message.type === 'end') { return diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts new file mode 100644 index 00000000000..1e2a9939ba2 --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnectionState } from '../../../shared/ssh-types' +import { useAppStore } from '@/store' +import { + applyRuntimeEnvironmentSshStateChanged, + connectRuntimeEnvironmentSshTarget, + hydrateRuntimeEnvironmentSshState, + resyncRuntimeEnvironmentSshTargets +} from './runtime-environment-ssh-state' +import { callRuntimeRpc } from './runtime-rpc-client' + +vi.mock('./runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc: vi.fn() +})) + +const callRuntimeRpcMock = vi.mocked(callRuntimeRpc) + +function connState( + targetId: string, + status: SshConnectionState['status'] = 'connected' +): SshConnectionState { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +type RpcResponses = { + targets?: { id: string; label: string }[] + labels?: Record + states?: Record + failListTargets?: boolean + failRemovedLabels?: boolean +} + +function installRpcResponses(responses: RpcResponses): void { + callRuntimeRpcMock.mockImplementation((_target, method, params) => { + switch (method) { + case 'ssh.listTargets': + if (responses.failListTargets) { + return Promise.reject(new Error('method not found')) + } + return Promise.resolve({ targets: responses.targets ?? [] } as never) + case 'ssh.listRemovedTargetLabels': + if (responses.failRemovedLabels) { + return Promise.reject(new Error('method not found')) + } + return Promise.resolve({ labels: responses.labels ?? {} } as never) + case 'ssh.getState': { + const targetId = (params as { targetId: string }).targetId + return Promise.resolve({ state: responses.states?.[targetId] ?? null } as never) + } + default: + return Promise.reject(new Error(`unexpected method ${method}`)) + } + }) +} + +let envCounter = 0 +function nextEnvId(): string { + envCounter += 1 + return `env-${envCounter}` +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + callRuntimeRpcMock.mockReset() +}) + +describe('hydrateRuntimeEnvironmentSshState', () => { + it('populates the environment bucket with targets, tombstones, and per-target states', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [ + { id: 'ssh-1', label: 'devbox' }, + { id: 'ssh-2', label: 'buildbox' } + ], + labels: { 'ssh-old': 'retired box' }, + states: { 'ssh-1': connState('ssh-1', 'connected') } + }) + + await hydrateRuntimeEnvironmentSshState(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') + expect(bucket?.removedTargetLabels.get('ssh-old')).toBe('retired box') + expect(bucket?.connectionStates.get('ssh-1')?.status).toBe('connected') + // ssh-2 had no live state: absent, so reads fall back to 'disconnected'. + expect(bucket?.connectionStates.has('ssh-2')).toBe(false) + // Local maps stay untouched. + expect(useAppStore.getState().sshTargetLabels.size).toBe(0) + expect(useAppStore.getState().sshTargetsHydrated).toBe(false) + // Every call was routed to the owning environment. + for (const [target] of callRuntimeRpcMock.mock.calls) { + expect(target).toEqual({ kind: 'environment', environmentId: envId }) + } + }) + + it('skips refetching when already hydrated unless forced', async () => { + const envId = nextEnvId() + installRpcResponses({ targets: [] }) + await hydrateRuntimeEnvironmentSshState(envId) + const callsAfterFirst = callRuntimeRpcMock.mock.calls.length + + await hydrateRuntimeEnvironmentSshState(envId) + expect(callRuntimeRpcMock.mock.calls.length).toBe(callsAfterFirst) + + await hydrateRuntimeEnvironmentSshState(envId, { force: true }) + expect(callRuntimeRpcMock.mock.calls.length).toBeGreaterThan(callsAfterFirst) + }) + + it('leaves the bucket un-hydrated when the host lacks the ssh RPC methods', async () => { + const envId = nextEnvId() + installRpcResponses({ failListTargets: true }) + + await expect(hydrateRuntimeEnvironmentSshState(envId)).rejects.toThrow() + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated ?? false).toBe(false) + }) + + it('still hydrates the target list when the removed-labels fetch fails', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-1', label: 'devbox' }], + failRemovedLabels: true + }) + + await hydrateRuntimeEnvironmentSshState(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') + }) +}) + +describe('applyRuntimeEnvironmentSshStateChanged', () => { + it('applies a known target state directly into the owning bucket without RPC', () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', connState('ssh-1', 'disconnected')) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status + ).toBe('disconnected') + expect(callRuntimeRpcMock).not.toHaveBeenCalled() + // Local map untouched. + expect(useAppStore.getState().sshConnectionStates.size).toBe(0) + }) + + it('does not touch another environment bucket or local state (no cross-pollution)', () => { + const envA = nextEnvId() + const envB = nextEnvId() + useAppStore.getState().setEnvironmentSshTargetsMetadata(envA, [{ id: 'ssh-1', label: 'a' }]) + useAppStore.getState().setEnvironmentSshTargetsMetadata(envB, [{ id: 'ssh-1', label: 'b' }]) + const bucketBBefore = useAppStore.getState().sshStateByEnvironment.get(envB) + const localStatesBefore = useAppStore.getState().sshConnectionStates + + applyRuntimeEnvironmentSshStateChanged(envA, 'ssh-1', connState('ssh-1', 'connected')) + + const state = useAppStore.getState() + expect(state.sshStateByEnvironment.get(envA)?.connectionStates.get('ssh-1')?.status).toBe( + 'connected' + ) + expect(state.sshStateByEnvironment.get(envB)).toBe(bucketBBefore) + expect(state.sshStateByEnvironment.get(envB)?.connectionStates.size).toBe(0) + expect(state.sshConnectionStates).toBe(localStatesBefore) + expect(state.sshConnectionStates.size).toBe(0) + }) + + it('re-fetches the authoritative target list for an unknown target instead of trusting the event', async () => { + const envId = nextEnvId() + // The event races a removal: the authoritative list does not contain it. + installRpcResponses({ targets: [], labels: { 'ssh-gone': 'old devbox' } }) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-gone', connState('ssh-gone', 'disconnected')) + await vi.waitFor(() => { + expect(useAppStore.getState().sshStateByEnvironment.get(envId)?.targetsHydrated).toBe(true) + }) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + // The trailing event must not resurrect the removed target's state. + expect(bucket?.connectionStates.has('ssh-gone')).toBe(false) + expect(bucket?.removedTargetLabels.get('ssh-gone')).toBe('old devbox') + }) + + it('picks up a just-added target through the forced refresh', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'fresh box' }], + states: { 'ssh-new': connState('ssh-new', 'connecting') } + }) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-new', connState('ssh-new', 'connecting')) + await vi.waitFor(() => { + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.targetLabels.get('ssh-new') + ).toBe('fresh box') + }) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-new') + ?.status + ).toBe('connecting') + }) +}) + +describe('connectRuntimeEnvironmentSshTarget', () => { + it('routes ssh.connect to the owning environment and mirrors the returned state', async () => { + const envId = nextEnvId() + callRuntimeRpcMock.mockResolvedValue({ state: connState('ssh-1', 'connected') } as never) + + const state = await connectRuntimeEnvironmentSshTarget(envId, 'ssh-1') + + expect(state?.status).toBe('connected') + expect(callRuntimeRpcMock).toHaveBeenCalledWith( + { kind: 'environment', environmentId: envId }, + 'ssh.connect', + { targetId: 'ssh-1' }, + expect.anything() + ) + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status + ).toBe('connected') + expect(useAppStore.getState().sshConnectionStates.size).toBe(0) + }) + + it('propagates connect failures without writing any state', async () => { + const envId = nextEnvId() + callRuntimeRpcMock.mockRejectedValue(new Error('SSH target not found')) + + await expect(connectRuntimeEnvironmentSshTarget(envId, 'ssh-dead')).rejects.toThrow( + 'SSH target not found' + ) + expect(useAppStore.getState().sshStateByEnvironment.has(envId)).toBe(false) + }) +}) + +describe('resyncRuntimeEnvironmentSshTargets', () => { + it('applies the target list even when the removed-labels refresh fails', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-live', label: 'devbox' }], + failRemovedLabels: true + }) + + await resyncRuntimeEnvironmentSshTargets(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetLabels.get('ssh-live')).toBe('devbox') + expect(bucket?.targetsHydrated).toBe(true) + }) +}) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts new file mode 100644 index 00000000000..263d444832c --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -0,0 +1,165 @@ +import { useAppStore } from '@/store' +import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' +import { callRuntimeRpc } from './runtime-rpc-client' + +/** + * Mirrors a remote Orca server's own SSH targets into that environment's + * per-environment SSH bucket (store slice `runtime-environment-ssh`), so a + * desktop client attached to the server gets live reconnect overlays for the + * server's SSH-backed workspaces (STA-1468, desktop topology). Never touches + * the local SSH maps. + */ + +const SSH_RPC_TIMEOUT_MS = 15_000 + +function environmentTarget(environmentId: string): { kind: 'environment'; environmentId: string } { + return { kind: 'environment', environmentId } +} + +async function fetchEnvironmentSshTargets(environmentId: string): Promise { + const { targets } = await callRuntimeRpc<{ targets: SshTarget[] }>( + environmentTarget(environmentId), + 'ssh.listTargets', + undefined, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + return targets +} + +/** Applies the environment's target list, then best-effort removal tombstones. + * Targets land first — a removed-labels failure must not discard them + * (they alone are enough evidence for the ghost-host derivation). */ +async function syncEnvironmentSshTargetMetadata(environmentId: string): Promise { + const targets = await fetchEnvironmentSshTargets(environmentId) + useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets) + try { + const { labels } = await callRuntimeRpc<{ labels: Record }>( + environmentTarget(environmentId), + 'ssh.listRemovedTargetLabels', + undefined, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + useAppStore.getState().setEnvironmentRemovedSshTargetLabels(environmentId, labels) + } catch { + // Best-effort — a missing map just falls back to the raw target id. + } + return targets +} + +async function fetchEnvironmentSshConnectionStates( + environmentId: string, + targets: readonly SshTarget[] +): Promise { + for (const target of targets) { + try { + const { state } = await callRuntimeRpc<{ state: SshConnectionState | null }>( + environmentTarget(environmentId), + 'ssh.getState', + { targetId: target.id }, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + if (state) { + useAppStore.getState().setEnvironmentSshConnectionState(environmentId, target.id, state) + } + } catch { + // A missing state just reads as 'disconnected'; the overlay's Connect + // action and push events converge it. + } + } +} + +type HydrationEntry = { promise: Promise; rerunRequested: boolean } +const hydrationsInFlight = new Map() + +async function runEnvironmentSshHydration(environmentId: string): Promise { + const targets = await syncEnvironmentSshTargetMetadata(environmentId) + await fetchEnvironmentSshConnectionStates(environmentId, targets) +} + +/** + * Fetches the environment's SSH targets, removal tombstones, and per-target + * connection states into its bucket. Single-flight per environment; a `force` + * request during an in-flight run schedules exactly one follow-up run so a + * refresh triggered by a just-added target can't be swallowed by a stale + * in-flight fetch. + * + * Hosts without the ssh.* RPC methods fail here and leave the bucket + * un-hydrated — reads then resolve to "unknown", never to destructive UI. + */ +export async function hydrateRuntimeEnvironmentSshState( + environmentId: string, + options: { force?: boolean } = {} +): Promise { + const inFlight = hydrationsInFlight.get(environmentId) + if (inFlight) { + if (options.force) { + inFlight.rerunRequested = true + } + return inFlight.promise + } + const bucket = useAppStore.getState().sshStateByEnvironment.get(environmentId) + if (!options.force && bucket?.targetsHydrated) { + return + } + const entry: HydrationEntry = { promise: Promise.resolve(), rerunRequested: false } + entry.promise = (async () => { + try { + await runEnvironmentSshHydration(environmentId) + while (entry.rerunRequested) { + entry.rerunRequested = false + await runEnvironmentSshHydration(environmentId) + } + } finally { + hydrationsInFlight.delete(environmentId) + } + })() + hydrationsInFlight.set(environmentId, entry) + return entry.promise +} + +/** + * Applies a `sshStateChanged` runtime client event from `environmentId` to + * that environment's bucket. For a target the bucket doesn't know yet + * (added after hydration, or a disconnect racing a removal), the authoritative + * target list is re-fetched instead of trusting the event: the forced + * hydration also re-reads the state, so a removed target's trailing event + * can't resurrect it. + */ +export function applyRuntimeEnvironmentSshStateChanged( + environmentId: string, + targetId: string, + state: SshConnectionState +): void { + const store = useAppStore.getState() + const bucket = store.sshStateByEnvironment.get(environmentId) + if (bucket?.targetsHydrated && bucket.targetLabels.has(targetId)) { + store.setEnvironmentSshConnectionState(environmentId, targetId, state) + return + } + void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) +} + +/** Connects the environment's own SSH target via its runtime RPC and mirrors + * the returned state into the bucket (ssh.connect can resolve before the + * push event lands). */ +export async function connectRuntimeEnvironmentSshTarget( + environmentId: string, + targetId: string +): Promise { + const { state } = await callRuntimeRpc<{ state: SshConnectionState | null }>( + environmentTarget(environmentId), + 'ssh.connect', + { targetId }, + { timeoutMs: 60_000 } + ) + if (state) { + useAppStore.getState().setEnvironmentSshConnectionState(environmentId, targetId, state) + } + return state +} + +/** Resyncs the environment's target metadata after a failed connect so a + * stale overlay converges to the ghost/re-adopted state (STA-1468). */ +export async function resyncRuntimeEnvironmentSshTargets(environmentId: string): Promise { + await syncEnvironmentSshTargetMetadata(environmentId) +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index e75bbffa5c9..19941c75cbe 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -23,6 +23,7 @@ import { createOpenCodeUsageSlice } from './slices/opencode-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' +import { createRuntimeEnvironmentSshSlice } from './slices/runtime-environment-ssh' import { createAgentStatusSlice } from './slices/agent-status' import { createPaneForegroundAgentSlice } from './slices/pane-foreground-agent' import { createDiffCommentsSlice } from './slices/diffComments' @@ -61,6 +62,7 @@ export const useAppStore = create()((...a) => ({ ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index e4862f12913..f1538a53fb2 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -128,6 +128,7 @@ import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' +import { createRuntimeEnvironmentSshSlice } from './runtime-environment-ssh' import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' @@ -165,6 +166,7 @@ function createTestStore() { ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts new file mode 100644 index 00000000000..22b5015884b --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createTestStore } from './store-test-helpers' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from './runtime-environment-ssh' + +const ENV_A = 'env-a' +const ENV_B = 'env-b' + +function connState( + targetId: string, + status: SshConnectionState['status'] = 'connected' +): SshConnectionState { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +function markReachable(store: ReturnType, environmentId: string): void { + store.getState().setRuntimeEnvironmentStatus(environmentId, { + status: { runtimeId: environmentId } as RuntimeStatus, + checkedAt: Date.now() + }) +} + +describe('runtime-environment-ssh slice', () => { + it('routes state into the owning environment bucket without touching local maps or other buckets', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + const localStatesBefore = store.getState().sshConnectionStates + const localLabelsBefore = store.getState().sshTargetLabels + const bucketBBefore = store.getState().sshStateByEnvironment.get(ENV_B) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, { 'ssh-dead': 'old box' }) + + const state = store.getState() + // Local maps: bit-identical (same references, still empty). + expect(state.sshConnectionStates).toBe(localStatesBefore) + expect(state.sshTargetLabels).toBe(localLabelsBefore) + expect(state.sshConnectionStates.size).toBe(0) + expect(state.sshTargetsHydrated).toBe(false) + // Environment B's bucket: untouched reference. + expect(state.sshStateByEnvironment.get(ENV_B)).toBe(bucketBBefore) + // Environment A's bucket: populated. + const bucketA = state.sshStateByEnvironment.get(ENV_A) + expect(bucketA?.connectionStates.get('ssh-a')?.status).toBe('connected') + expect(bucketA?.targetLabels.get('ssh-a')).toBe('a-box') + expect(bucketA?.removedTargetLabels.get('ssh-dead')).toBe('old box') + expect(bucketA?.targetsHydrated).toBe(true) + }) + + it('local slice writes never leak into environment buckets', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + const bucketBefore = store.getState().sshStateByEnvironment.get(ENV_A) + + store.getState().setSshConnectionState('ssh-a', connState('ssh-a', 'disconnected')) + store.getState().setSshTargetsMetadata([{ id: 'ssh-a', label: 'local a' } as never]) + + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.connectionStates.size).toBe(0) + }) + + it('keeps references stable when applying an identical state or target list', () => { + const store = createTestStore() + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + const mapBefore = store.getState().sshStateByEnvironment + const bucketBefore = mapBefore.get(ENV_A) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, {}) + + expect(store.getState().sshStateByEnvironment).toBe(mapBefore) + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) + }) + + it('flips the hydrated flag on the first fetch of an empty target list', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetsHydrated).toBe(true) + }) + + it('drops only the detached environment bucket on retain', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + store.getState().setSshTargetsMetadata([{ id: 'ssh-local', label: 'local' } as never]) + const bucketB = store.getState().sshStateByEnvironment.get(ENV_B) + const localLabels = store.getState().sshTargetLabels + + store.getState().retainEnvironmentSshState([ENV_B]) + + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + expect(store.getState().sshStateByEnvironment.get(ENV_B)).toBe(bucketB) + expect(store.getState().sshTargetLabels).toBe(localLabels) + }) + + it('detaching an environment through setRuntimeEnvironments drops its bucket', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + + store.getState().setRuntimeEnvironments([{ id: ENV_B, name: 'B', createdAt: 0 } as never]) + + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + expect(store.getState().sshStateByEnvironment.has(ENV_B)).toBe(true) + }) + + it('markEnvironmentSshStateStale downgrades to unknown but keeps labels for display', () => { + const store = createTestStore() + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + expect(selectRuntimeAwareSshStatus(store.getState(), ENV_A, 'ssh-a')).toBe('connected') + + store.getState().markEnvironmentSshStateStale(ENV_A) + + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetsHydrated).toBe(false) + expect(bucket?.connectionStates.size).toBe(0) + expect(bucket?.targetLabels.get('ssh-a')).toBe('a-box') + // Un-hydrated bucket reads as unknown — no overlay, no removal evidence. + expect(selectRuntimeAwareSshStatus(store.getState(), ENV_A, 'ssh-a')).toBeNull() + expect(selectRuntimeAwareSshTargetRemoved(store.getState(), ENV_A, 'ssh-a')).toBe(false) + }) + + describe('selectors', () => { + it('resolve the local maps when the owning environment is null', () => { + const store = createTestStore() + store.getState().setSshConnectionState('ssh-local', connState('ssh-local', 'disconnected')) + store.getState().setSshTargetsMetadata([{ id: 'ssh-local', label: 'local box' } as never]) + // A same-id target in a bucket must not shadow the local resolution. + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, null, 'ssh-local')).toBe('disconnected') + expect(selectRuntimeAwareSshTargetLabel(state, null, 'ssh-local')).toBe('local box') + expect(selectRuntimeAwareSshTargetRemoved(state, null, 'ssh-local')).toBe(false) + expect(selectRuntimeAwareSshTargetRemoved(state, null, 'ssh-gone')).toBe(true) + }) + + it('resolve the owning environment bucket, not local state, for a remote-owned target', () => { + const store = createTestStore() + markReachable(store, ENV_A) + // Same target id exists locally as connected — must not leak into the + // environment read. + store.getState().setSshConnectionState('ssh-1', connState('ssh-1', 'connected')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-1', label: 'devbox' }]) + store + .getState() + .setEnvironmentSshConnectionState(ENV_A, 'ssh-1', connState('ssh-1', 'disconnected')) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, ENV_A, 'ssh-1')).toBe('disconnected') + expect(selectRuntimeAwareSshTargetLabel(state, ENV_A, 'ssh-1')).toBe('devbox') + expect(selectRuntimeAwareSshTargetRemoved(state, ENV_A, 'ssh-1')).toBe(false) + }) + + it('return unknown (null status) for an unreachable environment even with a hydrated bucket', () => { + const store = createTestStore() + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-1', label: 'devbox' }]) + store + .getState() + .setEnvironmentSshConnectionState(ENV_A, 'ssh-1', connState('ssh-1', 'disconnected')) + // Environment probe now records unreachable. + store.getState().setRuntimeEnvironmentStatus(ENV_A, { status: null, checkedAt: Date.now() }) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, ENV_A, 'ssh-1')).toBeNull() + expect(selectRuntimeAwareSshTargetRemoved(state, ENV_A, 'ssh-1')).toBe(false) + }) + + it('never report removal from an un-hydrated bucket, and report it from a hydrated one', () => { + const store = createTestStore() + markReachable(store, ENV_A) + const before = store.getState() + expect(selectRuntimeAwareSshTargetRemoved(before, ENV_A, 'ssh-ghost')).toBe(false) + expect(selectRuntimeAwareSshStatus(before, ENV_A, 'ssh-ghost')).toBeNull() + + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + const after = store.getState() + expect(selectRuntimeAwareSshTargetRemoved(after, ENV_A, 'ssh-ghost')).toBe(true) + // Removal tombstones win the label fallback for ghost hosts. + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, { 'ssh-ghost': 'old devbox' }) + expect(selectRuntimeAwareSshTargetLabel(store.getState(), ENV_A, 'ssh-ghost')).toBe( + 'old devbox' + ) + }) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts new file mode 100644 index 00000000000..16ea3929df4 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -0,0 +1,263 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { + SshConnectionState, + SshConnectionStatus, + SshTarget +} from '../../../../shared/ssh-types' +import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' + +/** + * SSH state of one remote Orca server's own SSH targets, mirrored on this + * client. Kept strictly separate from the local `SshSlice` maps so a remote + * machine's targets can never pollute local SSH settings, pickers, or the + * execution-host registry — and vice versa (STA-1468, desktop topology). + */ +export type RuntimeEnvironmentSshBucket = { + connectionStates: Map + targetLabels: Map + removedTargetLabels: Map + /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence + * from `targetLabels` only counts as target removal once a target list + * actually loaded from that environment (even an empty one). */ + targetsHydrated: boolean +} + +export type RuntimeEnvironmentSshSlice = { + /** + * Per-runtime-environment SSH state buckets, keyed by environment id. + * Do NOT read this map directly from components — use the + * `selectRuntimeAwareSsh*` selectors below, which route between the local + * SSH maps (`environmentId === null`) and the owning environment's bucket. + */ + sshStateByEnvironment: Map + setEnvironmentSshConnectionState: ( + environmentId: string, + targetId: string, + state: SshConnectionState + ) => void + setEnvironmentSshTargetsMetadata: ( + environmentId: string, + targets: Pick[] + ) => void + setEnvironmentRemovedSshTargetLabels: ( + environmentId: string, + labels: Record + ) => void + /** Transport to the environment dropped: its mirrored SSH state can no + * longer be trusted (it may hold a pre-drop "connected"). Downgrades the + * bucket to unhydrated and clears connection states so reads resolve to + * "unknown" until a reconnect re-hydrates. */ + markEnvironmentSshStateStale: (environmentId: string) => void + removeEnvironmentSshState: (environmentId: string) => void + /** Drops buckets for environments no longer in the saved set. */ + retainEnvironmentSshState: (environmentIds: Iterable) => void +} + +const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { + connectionStates: new Map(), + targetLabels: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: false +} + +function getBucket( + buckets: Map, + environmentId: string +): RuntimeEnvironmentSshBucket { + return buckets.get(environmentId) ?? EMPTY_BUCKET +} + +function withBucket( + s: Pick, + environmentId: string, + bucket: RuntimeEnvironmentSshBucket +): Pick { + const next = new Map(s.sshStateByEnvironment) + next.set(environmentId, bucket) + return { sshStateByEnvironment: next } +} + +function removedLabelsEqual(current: Map, labels: Record): boolean { + const entries = Object.entries(labels) + return ( + entries.length === current.size && entries.every(([id, label]) => current.get(id) === label) + ) +} + +export const createRuntimeEnvironmentSshSlice: StateCreator< + AppState, + [], + [], + RuntimeEnvironmentSshSlice +> = (set) => ({ + sshStateByEnvironment: new Map(), + + setEnvironmentSshConnectionState: (environmentId, targetId, state) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (sshConnectionStatesEqual(bucket.connectionStates.get(targetId), state)) { + return s + } + const connectionStates = new Map(bucket.connectionStates) + connectionStates.set(targetId, state) + return withBucket(s, environmentId, { ...bucket, connectionStates }) + }), + + setEnvironmentSshTargetsMetadata: (environmentId, targets) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (sshTargetLabelsEqual(bucket.targetLabels, targets)) { + // Why: an unchanged (even empty) list is still a successful load — the + // hydration flag must flip on the first fetch of an empty target set. + return bucket.targetsHydrated + ? s + : withBucket(s, environmentId, { ...bucket, targetsHydrated: true }) + } + return withBucket(s, environmentId, { + ...bucket, + targetLabels: new Map(targets.map((target) => [target.id, target.label])), + targetsHydrated: true + }) + }), + + setEnvironmentRemovedSshTargetLabels: (environmentId, labels) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (removedLabelsEqual(bucket.removedTargetLabels, labels)) { + return s + } + return withBucket(s, environmentId, { + ...bucket, + removedTargetLabels: new Map(Object.entries(labels)) + }) + }), + + markEnvironmentSshStateStale: (environmentId) => + set((s) => { + const bucket = s.sshStateByEnvironment.get(environmentId) + if (!bucket || (!bucket.targetsHydrated && bucket.connectionStates.size === 0)) { + return s + } + // Labels are kept so a re-hydrating overlay can still show a friendly + // host name; hydration=false alone forces reads back to "unknown". + return withBucket(s, environmentId, { + ...bucket, + connectionStates: new Map(), + targetsHydrated: false + }) + }), + + removeEnvironmentSshState: (environmentId) => + set((s) => { + if (!s.sshStateByEnvironment.has(environmentId)) { + return s + } + const next = new Map(s.sshStateByEnvironment) + next.delete(environmentId) + return { sshStateByEnvironment: next } + }), + + retainEnvironmentSshState: (environmentIds) => + set((s) => { + const keep = new Set(environmentIds) + let changed = false + const next = new Map(s.sshStateByEnvironment) + for (const id of next.keys()) { + if (!keep.has(id)) { + next.delete(id) + changed = true + } + } + return changed ? { sshStateByEnvironment: next } : s + }) +}) + +type RuntimeAwareSshReadState = Pick< + AppState, + | 'sshConnectionStates' + | 'sshTargetLabels' + | 'removedSshTargetLabels' + | 'sshTargetsHydrated' + | 'sshStateByEnvironment' +> & + Partial> + +function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { + return Boolean(state.runtimeStatusByEnvironmentId?.get(environmentId)?.status) +} + +/** + * Reconnect-overlay status for an SSH target owned by `environmentId` (a + * remote Orca server) or by this machine (`environmentId === null`). + * + * Returns null when the state is unknown — environment unreachable or its + * bucket not hydrated — so callers show nothing rather than another machine's + * stale state. The runtime environment's own disconnected UI outranks any SSH + * overlay in that case. + */ +export function selectRuntimeAwareSshStatus( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): SshConnectionStatus | null { + if (environmentId === null) { + return state.sshConnectionStates.get(targetId)?.status ?? 'disconnected' + } + if (!isEnvironmentReachable(state, environmentId)) { + return null + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket?.targetsHydrated) { + return null + } + return bucket.connectionStates.get(targetId)?.status ?? 'disconnected' +} + +export function selectRuntimeAwareSshTargetLabel( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): string { + if (environmentId === null) { + return ( + state.sshTargetLabels.get(targetId) ?? + // Fall back to the removed target's last known label (ghost host) before + // the raw id, so a removed host shows its name instead of ssh--. + state.removedSshTargetLabels.get(targetId) ?? + targetId + ) + } + const bucket = state.sshStateByEnvironment.get(environmentId) + return bucket?.targetLabels.get(targetId) ?? bucket?.removedTargetLabels.get(targetId) ?? targetId +} + +/** + * True only on positive evidence that the target was removed on its owning + * host: a removal tombstone, or a hydrated target list that lacks the id. + * An unreachable environment or un-hydrated bucket never reports removal, so + * destructive "remove workspace" UI cannot be offered out of ignorance. + */ +export function selectRuntimeAwareSshTargetRemoved( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): boolean { + if (environmentId === null) { + return ( + state.removedSshTargetLabels.has(targetId) || + (state.sshTargetsHydrated && !state.sshTargetLabels.has(targetId)) + ) + } + if (!isEnvironmentReachable(state, environmentId)) { + return false + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket) { + return false + } + return ( + bucket.removedTargetLabels.has(targetId) || + (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)) + ) +} diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index b2c5eec0838..e9dae52e299 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -66,6 +66,8 @@ export const createRuntimeStatusSlice: StateCreator environment.id)) + // A detached environment's mirrored SSH state must not outlive it. + get().retainEnvironmentSshState?.(environments.map((environment) => environment.id)) }, setRuntimeEnvironmentStatus: (environmentId, status) => { diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index b1e09bf4ea6..df36fa5a983 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -31,6 +31,7 @@ import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' +import { createRuntimeEnvironmentSshSlice } from './runtime-environment-ssh' import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' @@ -77,6 +78,7 @@ export function createTestStore() { ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index b5c6f446fa2..3a266d67861 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -21,6 +21,7 @@ import type { OpenCodeUsageSlice } from './slices/opencode-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' +import type { RuntimeEnvironmentSshSlice } from './slices/runtime-environment-ssh' import type { AgentStatusSlice } from './slices/agent-status' import type { PaneForegroundAgentSlice } from './slices/pane-foreground-agent' import type { DiffCommentsSlice } from './slices/diffComments' @@ -56,6 +57,7 @@ export type AppState = RepoSlice & BrowserSlice & RateLimitSlice & SshSlice & + RuntimeEnvironmentSshSlice & AgentStatusSlice & PaneForegroundAgentSlice & DiffCommentsSlice & From c2468ab820af0545a9c5dd068b6c437893c07538 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 8 Jul 2026 16:24:04 -0400 Subject: [PATCH 068/152] fix(source-control): keep unresolved PR comment prompts retryable (#7466) (#7638) Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../SourceControlAgentActionDialog.test.tsx | 19 ++ .../pr-comments-list-selection.test.tsx | 12 ++ .../runSourceControlAgentActionStart.test.ts | 171 ++++++++++++++++++ .../runSourceControlAgentActionStart.ts | 23 ++- .../src/lib/launch-agent-in-new-tab.test.ts | 137 +++++++++++++- .../src/lib/launch-agent-in-new-tab.ts | 51 ++++-- 6 files changed, 386 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index 63c2987b41c..d0b4f798a43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -247,6 +247,25 @@ describe('SourceControlAgentActionDialog', () => { expect(mocks.toastError).toHaveBeenCalledTimes(1) }) + it('keeps prompt editing available when async auto-start delivery fails', async () => { + mocks.onStart.mockImplementation(async () => { + await Promise.resolve() + return false + }) + renderControlledDialog({ + baseCommandInput: 'Resolve queued comments.', + savedCommandInputTemplate: '{basePrompt}' + }) + expect(container.textContent).not.toContain('Launch agent') + await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(container.textContent).toContain('Launch agent')) + + expect(container.querySelector('textarea')?.value).toContain('{basePrompt}') + expect(container.textContent).toContain('Start agent') + expect(mocks.onLaunched).not.toHaveBeenCalled() + expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false) + }) + it('does not auto-start when a saved receipt appears after the dialog is already open', async () => { let setSavedAgentId: (agent: TuiAgent | null) => void = () => {} diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index 61924494157..ec89aa87288 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -326,6 +326,18 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('keeps queued comments selected when clearRequest is null', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] + renderList({ comments }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ comments, clearRequest: null }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + }) + it('keeps a clear request pending until its review context is mounted', () => { const queuedComments = [ comment({ diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts new file mode 100644 index 00000000000..345f1b6a09b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + focusTerminalTabSurface: vi.fn(), + launchAgentInNewTab: vi.fn(), + onClose: vi.fn(), + onLaunched: vi.fn(), + onSaveAgentDefault: vi.fn(), + onStart: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: mocks.focusTerminalTabSurface +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError } +})) + +import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' + +function buildArgs( + overrides: Partial[0]> = {} +): Parameters[0] { + return { + selectedAgent: 'codex', + trimmedCommandInput: 'Fix the bug', + agentArgs: '--model gpt-5', + commandTemplate: '{basePrompt}', + saveTargetValue: 'none', + actionId: 'resolveComments', + repoId: null, + settings: null, + repo: null, + worktreeId: 'wt-1', + groupId: 'group-1', + promptDelivery: 'submit-after-ready', + launchPlatform: 'linux', + launchSource: 'source_control_recovery', + onStart: undefined, + onSaveAgentDefault: mocks.onSaveAgentDefault, + onLaunched: mocks.onLaunched, + onClose: mocks.onClose, + ...overrides + } +} + +describe('runSourceControlAgentActionStart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('waits for deferred prompt delivery before confirming a source-control launch', async () => { + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) + }) + + await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(true) + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + expect(mocks.onClose).toHaveBeenCalledTimes(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('keeps the source-control dialog open when deferred prompt delivery fails', async () => { + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false }) + }) + + await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false) + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + expect(mocks.onLaunched).not.toHaveBeenCalled() + expect(mocks.onClose).not.toHaveBeenCalled() + expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.') + }) + + it('does not show a generic start failure when deferred delivery already notified the user', async () => { + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true }) + }) + + await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false) + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + expect(mocks.onLaunched).not.toHaveBeenCalled() + expect(mocks.onClose).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('logs and treats a rejected promptDeliveryResult as a launch failure', async () => { + const error = new Error('boom') + const originalConsole = console + const consoleError = vi.fn() + vi.stubGlobal('console', { ...originalConsole, error: consoleError }) + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.reject(error) + }) + + try { + await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false) + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + expect(consoleError).toHaveBeenCalledWith('promptDeliveryResult rejected', error) + expect(mocks.onLaunched).not.toHaveBeenCalled() + expect(mocks.onClose).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.') + } finally { + vi.stubGlobal('console', originalConsole) + } + }) + + it('keeps non-deferred tab launches immediate', async () => { + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true + }) + + await expect( + runSourceControlAgentActionStart(buildArgs({ promptDelivery: 'draft' })) + ).resolves.toBe(true) + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + expect(mocks.onClose).toHaveBeenCalledTimes(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('keeps injected onStart successes immediate', async () => { + const onStart = vi.fn().mockResolvedValue(true) + + await expect( + runSourceControlAgentActionStart( + buildArgs({ + onStart, + worktreeId: undefined, + groupId: undefined + }) + ) + ).resolves.toBe(true) + + expect(onStart).toHaveBeenCalledWith({ + agent: 'codex', + commandInput: 'Fix the bug', + agentArgs: '--model gpt-5' + }) + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + expect(mocks.onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index f180cc56dfb..ee6c24e326c 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -62,6 +62,7 @@ export async function runSourceControlAgentActionStart({ onClose }: RunSourceControlAgentActionStartArgs): Promise { let launched = false + let launchFailureNotified = false if (onStart) { launched = await onStart({ agent: selectedAgent, @@ -83,14 +84,26 @@ export async function runSourceControlAgentActionStart({ if (result?.tabId) { focusTerminalTabSurface(result.tabId) } + if (result?.promptDeliveryResult) { + try { + const deliveryResult = await result.promptDeliveryResult + launched = deliveryResult.delivered + launchFailureNotified = deliveryResult.failureNotified + } catch (error) { + console.error('promptDeliveryResult rejected', error) + launched = false + } + } } if (!launched) { - toast.error( - translate( - 'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1', - 'Could not start the selected agent.' + if (!launchFailureNotified) { + toast.error( + translate( + 'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1', + 'Could not start the selected agent.' + ) ) - ) + } return false } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fd4dcf004c0..fd25fc85df6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -9,6 +9,7 @@ const mockPasteDraftWhenAgentReady = vi.fn() const mockSeedNativeChatLaunchPrompt = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() +const mockToastMessage = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -80,7 +81,7 @@ vi.mock('@/store', () => ({ const mockToastError = vi.fn() vi.mock('sonner', () => ({ - toast: { message: vi.fn(), error: mockToastError } + toast: { message: mockToastMessage, error: mockToastError } })) vi.mock('@/components/tab-bar/reconcile-order', () => ({ @@ -545,7 +546,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const prompt = 'x'.repeat(25_000) - launchAgentInNewTab({ + const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1', prompt, @@ -553,6 +554,7 @@ describe('launchAgentInNewTab', () => { launchPlatform: 'win32' }) + expect(result).not.toHaveProperty('promptDeliveryResult') expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ @@ -570,16 +572,46 @@ describe('launchAgentInNewTab', () => { ) }) + it('logs rejected non-deferred prompt delivery without exposing it to callers', async () => { + const error = new Error('paste failed') + const originalConsole = console + const consoleError = vi.fn() + vi.stubGlobal('console', { ...originalConsole, error: consoleError }) + mockPasteDraftWhenAgentReady.mockRejectedValue(error) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'x'.repeat(25_000) + + try { + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(result).not.toHaveProperty('promptDeliveryResult') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(consoleError).toHaveBeenCalledWith('Prompt delivery failed after launch', error) + } finally { + vi.stubGlobal('console', originalConsole) + } + }) + it('seeds working after Command Code submit-after-ready prompt delivery', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ + const result = launchAgentInNewTab({ agent: 'command-code', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) store.terminalLayoutsByTabId = { 'tab-1': { activeLeafId: LEAF_ID } } + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) await Promise.resolve() await Promise.resolve() @@ -610,17 +642,114 @@ describe('launchAgentInNewTab', () => { mockPasteDraftWhenAgentReady.mockResolvedValue(false) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ + const result = launchAgentInNewTab({ agent: 'command-code', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: false + }) await Promise.resolve() expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) + it('marks failed submit-after-ready delivery as notified after readiness timeout toast', async () => { + mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => { + onTimeout?.() + return Promise.resolve(false) + }) + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'command-code', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mockToastMessage).toHaveBeenCalledWith( + "Your prompt wasn't sent — paste it once the agent is ready." + ) + }) + + it('marks a cancelled submit-after-ready launch notified when the user closed the tab', async () => { + mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => { + onTimeout?.() + return Promise.resolve(false) + }) + // User closed the tab before the agent became ready — it is gone from the list. + store.tabsByWorktree = { 'wt-1': [] } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'command-code', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mockToastMessage).not.toHaveBeenCalled() + }) + + it('marks a cancelled submit-after-ready launch notified when the user switched worktrees', async () => { + mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => { + onTimeout?.() + return Promise.resolve(false) + }) + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] } + store.activeWorktreeId = 'wt-2' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'command-code', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mockToastMessage).not.toHaveBeenCalled() + }) + + it('leaves a genuine launch failure unnotified so the caller surfaces it', async () => { + mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => { + onTimeout?.() + return Promise.resolve(false) + }) + // PTY never spawned: a real failure, not a user cancellation. + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null } as never] } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'command-code', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: false + }) + expect(mockToastMessage).not.toHaveBeenCalled() + }) + it('queues per-launch CLI arguments without putting generated prompts in argv', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 2f06dca71a9..50397347791 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -69,6 +69,7 @@ export type LaunchAgentInNewTabResult = { tabId: string | null startupPlan: AgentStartupPlan pasteDraftAfterLaunch: boolean + promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> } | null /** @@ -153,6 +154,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI let pasteDraftAfterLaunch: string | null = null let submitPastedPrompt = false let forcePasteAfterLaunch = false + let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined if (hasPrompt && promptDelivery === 'submit-after-ready') { // Why: generated multi-line prompts are too large to echo through a shell @@ -300,9 +302,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // the user closed the tab or switched worktrees so the toast/telemetry // don't fire for user-initiated cancellation (mirrors the 5s launch // watchdog in QuickLaunchButton). - const tabId = tab.id - void deliverLaunchPromptToAgentTab({ - tabId, + let failureNotified = false + const deliveryPromise = deliverLaunchPromptToAgentTab({ + tabId: tab.id, content: pasteDraftAfterLaunch, agent, submit: submitPastedPrompt, @@ -310,28 +312,28 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI onTimeout: () => { const state = useAppStore.getState() const tabsForWorktree = state.tabsByWorktree[worktreeId] ?? [] - const tab = tabsForWorktree.find((t) => t.id === tabId) - // Why: if the PTY never spawned, QuickLaunch's 5s watchdog already - // surfaced the launch failure. Don't double-toast for the same root - // cause. Looking up directly in `worktreeId` (not scanning every - // worktree) also preserves "still in this worktree" intent. - if (!tab) { - return // tab closed by user + const currentTab = tabsForWorktree.find((t) => t.id === tab.id) + if (currentTab?.ptyId === null) { + // Why: PTY never spawned — a genuine launch failure. Stay silent so + // the single notice comes from the caller (source-control dialog + // toast, or QuickLaunch's watchdog); leaving failureNotified false lets it fire. + return } - if (tab.ptyId === null) { - return // launch failed; QuickLaunch handled the user-facing toast - } - if (state.activeWorktreeId !== worktreeId) { + if (!currentTab || state.activeWorktreeId !== worktreeId) { + // Why: user-initiated cancellation (closed the tab or switched + // worktrees) — mark notified so the deferred source-control caller + // suppresses its generic "couldn't start" toast too, not just this nudge. + failureNotified = true return } - const label = submitPastedPrompt ? 'prompt' : 'notes' toast.message( translate( 'auto.lib.launch.agent.in.new.tab.a5a1f7033f', "Your {{value0}} wasn't sent — paste it once the agent is ready.", - { value0: label } + { value0: submitPastedPrompt ? 'prompt' : 'notes' } ) ) + failureNotified = true track('agent_error', { error_class: 'paste_readiness_timeout', agent_kind: tuiAgentToAgentKind(agent) @@ -342,11 +344,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI if (agent === 'command-code' && submitPastedPrompt) { // Why: Command Code has no prompt-submit hook; when Orca submits a // generated prompt after readiness, seed working at delivery time. - seedCommandCodeSubmittedPromptStatus(tabId, trimmedPrompt) + seedCommandCodeSubmittedPromptStatus(tab.id, trimmedPrompt) } onPromptDelivered?.() } + return { delivered, failureNotified: !delivered && failureNotified } }) + if (promptDelivery === 'submit-after-ready') { + promptDeliveryResult = deliveryPromise + } else { + void deliveryPromise.catch((error) => + console.error('Prompt delivery failed after launch', error) + ) + } } else if (hasPrompt) { onPromptDelivered?.() } @@ -373,5 +383,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI order.push(tab.id) fresh.setTabBarOrder(worktreeId, order) - return { tabId: tab.id, startupPlan, pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null } + return { + tabId: tab.id, + startupPlan, + pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null, + ...(promptDeliveryResult ? { promptDeliveryResult } : {}) + } } From e3eec5c39fbc6d5e3bb1b90e23283c4954889502 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:54:06 -0700 Subject: [PATCH 069/152] Let pane-scoped evidence beat stale tab icon identity (#7755) --- .../pane-foreground-agent-tracker.ts | 19 +- .../terminal-pane/pty-connection.test.ts | 60 ++++++ .../use-tab-agent-sleeping-session.test.ts | 184 ++++++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 33 +++- 4 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 8e9d3732780..d3e613f6845 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -176,25 +176,24 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // can identify the foreground — that pair is exactly a leaked nested-shell // command under a full-screen agent (or a fast real shell command), so on a // no-identity pane confirm it rather than trusting the D as a prompt return. - // A pending confirming read counts too: user shell integrations double up - // Orca's OSC 133, and the duplicate D must re-confirm, not fast-path past - // the in-flight confirmation it just cancelled. - const commandReadWasPending = - scheduledReadReason === 'command' || - activeReadReason === 'command' || - scheduledReadReason === 'command-finished' || - activeReadReason === 'command-finished' + // ANY in-flight read counts: a command-start read, a prior confirming read + // (user shell integrations double up Orca's OSC 133), or the reattach/visible + // recovery probe. All three are attempts to establish this pane's identity, so + // a D that cancels one must re-confirm — never fast-path to shell, which the + // sampleVisiblePaneForegroundAgent gate would then latch, permanently hiding + // an idle reattached agent's icon (the "codex reattached at rest" bug). + const identityReadWasPending = scheduledReadReason !== null || activeReadReason !== null cancelPendingRead() if (!trackablePtyId()) { return } // Why: trust the 133;D and mark shell without an RPC only when nothing hints // at an agent — no prior agent evidence, no launch/hook identity, and no - // command-start read racing this finish. + // identity read racing this finish. if ( !hasForegroundAgentEvidence && deps.hasKnownAgentIdentity?.() !== true && - !commandReadWasPending + !identityReadWasPending ) { deps.publish({ agent: null, shellForeground: true }) return 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 9b43d897171..6e3746ab7e1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -14735,6 +14735,66 @@ describe('connectPanePty', () => { shellForeground: false }) }) + + it('re-confirms rather than trusting a leaked 133;D that races the recovery read', async () => { + // Why: an idle reattached agent (no launchAgent/hook/title) schedules the + // visible-pty recovery read. A full-screen agent's nested shell then leaks a + // 133;D onto the main PTY. Trusting that D would mark the pane shell- + // foreground and the sample gate would latch it, hiding the agent icon + // forever — instead the leak must re-confirm the foreground. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-reattach-idle-codex-leaked-d' + const tabId = `tab-${ptyId}` + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return sessionId ? { id: sessionId } : null + } + ) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + + const cacheKey = makePaneKey(tabId, LEAF_1) + dataCallbackRef.current?.('\x1b]133;D;0\x07') + + await advanceVisibleForegroundRead() + + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith(cacheKey, { + agent: null, + shellForeground: true + }) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: 'codex', + shellForeground: false + }) + }) + + it('never probes the foreground for a visible remote/SSH restored pane', async () => { + // Why: foreground reads are local-only (expensive RPCs, replayed OSC + // streams). isTrackablePtyId must keep remote/SSH panes off the recovery + // probe entirely, so the fix stays local-only. + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'remote:web-env-1@@pty-remote-idle-agent' + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId: 'tab-remote-idle-agent' }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) }) describe('terminal input liveness IPC gating (perf)', () => { diff --git a/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts new file mode 100644 index 00000000000..4406a1339fa --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + ResumableTuiAgent, + SleepingAgentSessionRecord +} from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalTab, TuiAgent } from '../../../shared/types' +import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent' + +const initialAppState = useAppStore.getInitialState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +let latestHookAgent: TuiAgent | null | undefined +const hookRoots: Root[] = [] + +function HookProbe({ tab }: { tab: TerminalTab }): null { + latestHookAgent = useTabAgent(tab) + return null +} + +async function flushHookEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderHookProbe(tab: TerminalTab): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { tab })) + }) + await flushHookEffects() + return root +} + +function sleepingRecord(paneKey: string, agent: ResumableTuiAgent): SleepingAgentSessionRecord { + return { + paneKey, + worktreeId: 'wt-1', + agent, + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('resolveTabAgentFromSignals sleeping-session precedence', () => { + it("prefers a hibernated pane's session identity over a stale reused launchAgent", () => { + // Why: a codex launch later reused for claude leaves a claude sleeping record + // for the pane. Its generic spinner title names no agent, so only the session + // record proves the launch identity went stale. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠐ Explain GitHub issue simply', + hookAgent: null, + sleepingSessionAgent: 'claude', + launchAgent: 'codex' + }) + ).toBe('claude') + }) + + it('keeps live hook identity ahead of a sleeping-session record', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: 'codex', + sleepingSessionAgent: 'claude', + launchAgent: 'codex' + }) + ).toBe('codex') + }) + + it('keeps an explicit title ahead of a conflicting sleeping-session record', () => { + // Why: sleeping identity ranks below title — a live/last-known explicit title + // is at least as fresh as the hibernation snapshot. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✳ Claude Code', + hookAgent: null, + sleepingSessionAgent: 'gemini', + launchAgent: 'codex' + }) + ).toBe('claude') + }) + + it('keeps a genuine tab icon when its sleeping record matches the launchAgent', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠐ working', + hookAgent: null, + sleepingSessionAgent: 'codex', + launchAgent: 'codex' + }) + ).toBe('codex') + }) +}) + +describe('useTabAgent sleeping-session', () => { + const originalApi = window.api + const getForegroundProcess = vi.fn() + const clearTabLaunchAgent = vi.fn() + const baseTab: TerminalTab = { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + + beforeEach(() => { + latestHookAgent = undefined + getForegroundProcess.mockReset() + clearTabLaunchAgent.mockReset() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + agentStatusByPaneKey: {}, + terminalLayoutsByTabId: {}, + clearTabLaunchAgent + }) + window.api = { + ...originalApi, + pty: { + ...originalApi?.pty, + getForegroundProcess + } + } as typeof window.api + }) + + afterEach(() => { + hookRoots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + window.api = originalApi + }) + + it('paints a hibernated pane with its sleeping-session agent over a stale launchAgent', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + // Why: the pane was launched as codex, then reused for claude and hibernated. + // No live hook/process remains and the frozen title names no agent, so the + // persisted session record is the only proof the launch identity went stale. + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { [paneKey]: sleepingRecord(paneKey, 'claude') } + }) + + await renderHookProbe({ ...baseTab, title: '⠐ Explain GitHub issue simply' }) + + expect(latestHookAgent).toBe('claude') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index e6f992fe74f..50420497155 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -79,6 +79,7 @@ export function resolveTabAgentFromSignals(args: { siblingCompletedHookAgent?: TuiAgent | null processAgent?: TuiAgent | null processShellForeground?: boolean + sleepingSessionAgent?: TuiAgent | null launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null @@ -132,14 +133,26 @@ export function resolveTabAgentFromSignals(args: { }) const activeLaunchAgent = launchedAgentExited ? null : launchAgent const processAgent = args.processAgent ?? null + const sleepingSessionAgent = args.sleepingSessionAgent ?? null // Why: titleAgent ranks ahead of launch/fallback hooks because, once the // pane has shown activity, a live explicit title is the freshest identity // signal — it beats a launchAgent gone stale through pane reuse. Before any // activity, titleAgent is null while launchAgent exists, so launch bootstrap // still wins the startup window. Process identity (the recognized foreground // process) is ground truth below hooks — it covers agents that emit neither - // hooks nor titles. - return focusedHookAgent ?? processAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent + // hooks nor titles. sleepingSessionAgent ranks above launch bootstrap: a + // hibernated pane's captured session identity is pane-scoped proof that the + // launch identity went stale (a codex launch later reused for claude), and it + // is the only live signal while the pane is asleep (its PTY, hooks, and + // process are all gone). + return ( + focusedHookAgent ?? + processAgent ?? + titleAgent ?? + sleepingSessionAgent ?? + activeLaunchAgent ?? + fallbackHookAgent + ) } /** @@ -155,10 +168,15 @@ export function resolveTabAgentFromSignals(args: { * command boundaries (local panes only); covers agents that emit neither * hooks nor titles, and its shell-foreground mark is title-independent * exit evidence. - * 3. launchAgent — what Orca launched here; instant bootstrap before hooks + * 3. Sleeping session identity — a hibernated pane's captured session record + * (agent + provider session). Ranks below title but above launchAgent + * because it is pane-scoped proof of reuse: a codex launch later reused for + * claude leaves a claude sleeping record, and while the pane is asleep this + * is the only live identity signal (its PTY, hooks, and process are gone). + * 4. launchAgent — what Orca launched here; instant bootstrap before hooks * arrive, cleared once hook/process/title evidence shows the launched * agent exited. - * 4. Title — legacy/unknown-session fallback, and the live override when a pane + * 5. Title — legacy/unknown-session fallback, and the live override when a pane * is reused: once the pane has shown activity, a title that explicitly names * a different agent than launchAgent wins over that stale launch identity. * Otherwise it is ignored while launchAgent exists, and generic spinner-only @@ -199,6 +217,12 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { ? Boolean(s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground) : false ) + // Why: a hibernated pane's persisted session record is pane-scoped evidence of + // which agent actually ran here — the freshest identity once the PTY, hook, + // and process signals are all gone, and proof a stale launchAgent was reused. + const sleepingSessionAgent = useAppStore((s) => + focusedPaneKey ? (s.sleepingAgentSessionsByPaneKey[focusedPaneKey]?.agent ?? null) : null + ) // The focused pane's PTY (single-pane tabs have exactly one leaf). Only used // to reset per-process-generation signals when the pane is respawned. @@ -319,6 +343,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { siblingCompletedHookAgent, processAgent, processShellForeground, + sleepingSessionAgent, launchAgent: tab.launchAgent }) } From 0784a7ea372c83f9f51f63de6cacfcd889cfe5b4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:59:42 -0700 Subject: [PATCH 070/152] fix(ssh): bridge the full Orca CLI over the SSH relay instead of a hardcoded command switch (#7771) The relay CLI shim on SSH remotes rejected every orchestration/mutation command with 'Unsupported SSH Orca CLI command' because the host handled relay CLI requests with a hand-rolled allowlist of five read-only-ish commands. The host now runs the real bundled orca CLI entry (same entry as the local shell command, via ELECTRON_RUN_AS_NODE) as a captured subprocess, so remote invocations get the full command surface by construction. Remote cwd is carried via ORCA_CLI_CWD so cwd-based selectors (--worktree active) resolve against the caller's remote directory; only Orca terminal-context env vars cross the bridge. Host-interactive commands (serve, claude-teams, agent-teams-tmux) get a targeted error, and the legacy in-process switch remains as a fallback when the host CLI entry cannot be launched. Relay-side request timeouts are raised to fit mutation and long-poll (--wait/--timeout-ms) commands, and stdin forwarding now covers *-stdin payload flags. Fixes #7716 Co-authored-by: Orca --- src/cli/index.test.ts | 32 +++ src/cli/index.ts | 14 +- .../ssh-remote-cli-host-passthrough.test.ts | 250 ++++++++++++++++ .../ssh/ssh-remote-cli-host-passthrough.ts | 270 ++++++++++++++++++ src/main/ssh/ssh-remote-orca-cli.test.ts | 182 ++++++++++-- src/main/ssh/ssh-remote-orca-cli.ts | 90 ++++-- src/relay/remote-cli-env.test.ts | 4 + src/relay/remote-cli-env.ts | 2 + src/relay/remote-cli-stdin.test.ts | 12 + src/relay/remote-cli-stdin.ts | 6 + src/relay/remote-cli-timeout.test.ts | 64 +++-- src/relay/remote-cli-timeout.ts | 65 +++-- 12 files changed, 906 insertions(+), 85 deletions(-) create mode 100644 src/main/ssh/ssh-remote-cli-host-passthrough.test.ts create mode 100644 src/main/ssh/ssh-remote-cli-host-passthrough.ts diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 12d4a64eef9..0ee5498bf85 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -434,6 +434,38 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledTimes(1) }) + it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => { + // Why: the SSH relay bridge runs the CLI on the Orca host with the remote + // shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must + // resolve against it, not the host process cwd. + process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src' + try { + queueFixtures( + callMock, + worktreeListFixture([ + buildWorktree('/tmp/repo', 'main'), + buildWorktree('/tmp/repo/feature', 'feature/foo') + ]), + okFixture('req_1', { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'current', '--json']) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { + worktree: 'id:repo::/tmp/repo/feature' + }) + } finally { + delete process.env.ORCA_CLI_CWD + } + }) + it.skipIf(process.platform === 'win32')( 'prepares and starts Claude Agent Teams in the current Orca terminal', async () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index e2934812ff5..e9c12859063 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,7 +25,19 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { ) } -export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { +// Why: the SSH relay bridge executes this CLI on the Orca host while the +// caller's shell cwd lives on the remote machine (which cannot be chdir'd +// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like +// `--worktree active` resolve against the caller's directory. +function resolveInvocationCwd(): string { + const override = process.env.ORCA_CLI_CWD + return typeof override === 'string' && override.length > 0 ? override : process.cwd() +} + +export async function main( + argv = process.argv.slice(2), + cwd = resolveInvocationCwd() +): Promise { if (argv[0] === 'agent-teams-tmux') { await runAgentTeamsTmuxShim(argv.slice(1)) return diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts new file mode 100644 index 00000000000..22920a66636 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -0,0 +1,250 @@ +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { + HostCliUnavailableError, + buildHostCliEnv, + resolveHostCliEntryPath, + resolveHostCliKillTimeoutMs, + runHostOrcaCliPassthrough +} from './ssh-remote-cli-host-passthrough' + +type FakeChild = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType; on: ReturnType } + kill: ReturnType +} + +function createFakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn(), on: vi.fn() } + child.kill = vi.fn() + return child +} + +const BASE_OPTIONS = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true +} + +describe('resolveHostCliEntryPath', () => { + it('uses the in-repo entry for dev builds and the unpacked asar entry when packaged', () => { + expect( + resolveHostCliEntryPath({ isPackaged: false, resourcesPath: '/r', appPath: '/host/app' }) + ).toBe(join('/host/app', 'out', 'cli', 'index.js')) + expect( + resolveHostCliEntryPath({ isPackaged: true, resourcesPath: '/r', appPath: '/host/app' }) + ).toBe(join('/r', 'app.asar.unpacked', 'out', 'cli', 'index.js')) + }) +}) + +describe('buildHostCliEnv', () => { + it('forwards only Orca terminal-context vars from the remote env', () => { + const env = buildHostCliEnv({ + hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, + remoteEnv: { + ORCA_TERMINAL_HANDLE: 'term_remote', + ORCA_WORKTREE_ID: 'repo::/home/alice/wt', + ORCA_PANE_KEY: 'pane-9', + ORCA_WORKSPACE_ID: 'ws-1', + // Why: these are remote-machine paths and must not leak into the host + // subprocess (PATH would break host binary lookup; user-data would + // retarget the CLI at a different local instance). + PATH: '/remote/bin', + ORCA_USER_DATA_PATH: '/remote/user-data' + }, + userDataPath: '/host/user-data', + remoteCwd: '/home/alice/wt/sub' + }) + + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt') + expect(env.ORCA_PANE_KEY).toBe('pane-9') + expect(env.ORCA_WORKSPACE_ID).toBe('ws-1') + expect(env.PATH).toBe('/host/bin') + expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') + expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') + expect(env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') + }) +}) + +describe('resolveHostCliKillTimeoutMs', () => { + it('extends the kill timer past an explicit --timeout-ms budget', () => { + expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe( + 1_920_000 + ) + expect(resolveHostCliKillTimeoutMs(['orchestration', 'check', '--timeout-ms=5000'])).toBe( + 600_000 + ) + expect(resolveHostCliKillTimeoutMs(['worktree', 'list'])).toBe(600_000) + }) +}) + +describe('runHostOrcaCliPassthrough', () => { + it('spawns the bundled CLI entry with the remote argv and returns captured output', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { + argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], + cwd: '/home/alice/wt', + env: { ORCA_TERMINAL_HANDLE: 'term_remote' } + }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.stdout.emit('data', Buffer.from('{"ok":true}\n')) + child.stderr.emit('data', Buffer.from('warn\n')) + child.emit('close', 0) + + const result = await resultPromise + expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: 'warn\n', exitCode: 0 }) + + expect(spawn).toHaveBeenCalledTimes(1) + const [execPath, args, options] = spawn.mock.calls[0] as unknown as [ + string, + string[], + { env: NodeJS.ProcessEnv } + ] + expect(execPath).toBe('/host/electron') + expect(args).toEqual([ + '/host/app/out/cli/index.js', + 'orchestration', + 'task-create', + '--spec', + 'do the thing', + '--json' + ]) + expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(options.env.ORCA_CLI_CWD).toBe('/home/alice/wt') + expect(options.env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + // Why: stdin must be closed even without a payload so CLI handlers that + // stream stdin see EOF instead of hanging forever. + expect(child.stdin.end).toHaveBeenCalledWith() + }) + + it('pipes a stdin payload to the CLI subprocess', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { + argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], + cwd: '/home/alice/wt', + env: {}, + stdin: 'comment body' + }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.emit('close', 0) + await resultPromise + + expect(child.stdin.end).toHaveBeenCalledWith('comment body') + }) + + it('propagates non-zero exit codes', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['worktree', 'show'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.stderr.emit('data', Buffer.from('boom\n')) + child.emit('close', 3) + + await expect(resultPromise).resolves.toEqual({ stdout: '', stderr: 'boom\n', exitCode: 3 }) + }) + + it('throws HostCliUnavailableError when the CLI entry is missing', async () => { + const spawn = vi.fn() + await expect( + runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } + ) + ).rejects.toBeInstanceOf(HostCliUnavailableError) + expect(spawn).not.toHaveBeenCalled() + }) + + it('throws HostCliUnavailableError when the subprocess fails to launch', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.emit('error', new Error('spawn ENOENT')) + + await expect(resultPromise).rejects.toBeInstanceOf(HostCliUnavailableError) + }) + + it('kills the subprocess and reports an error when the kill timeout elapses', async () => { + vi.useFakeTimers() + try { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['terminal', 'wait', '--for', 'exit'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } + ) + + await vi.advanceTimersByTimeAsync(1001) + const result = await resultPromise + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('timed out') + } finally { + vi.useRealTimers() + } + }) + + it('caps runaway output instead of buffering it unbounded', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['terminal', 'read'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + const chunk = Buffer.alloc(3 * 1024 * 1024, 97) + for (let i = 0; i < 4; i += 1) { + child.stdout.emit('data', chunk) + } + child.emit('close', 0) + + const result = await resultPromise + expect(result.stdout.length).toBeLessThanOrEqual(8 * 1024 * 1024 + 64) + expect(result.stdout).toContain('output truncated') + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts new file mode 100644 index 00000000000..3ca55edfd69 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -0,0 +1,270 @@ +// Why: the SSH relay shim (`~/.orca-relay/bin/orca`) forwards CLI invocations +// to the host app. Instead of re-implementing every command in a hand-rolled +// switch (the cause of "Unsupported SSH Orca CLI command", #7716), the host +// runs the real bundled `orca` CLI entry in Electron node mode — the same +// entry the local shell command uses — so remote invocations get the full +// command surface (orchestration, worktree, terminal, ...) by construction. +import { app } from 'electron' +import { spawn as nodeSpawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { getCanonicalUserDataPath } from '../persistence' + +export type RemoteOrcaCliRequest = { + argv: string[] + cwd: string + env: Record + stdin?: string +} + +export type RemoteOrcaCliResult = { + stdout: string + stderr: string + exitCode: number +} + +export type HostCliPassthroughOptions = { + execPath?: string + cliEntryPath?: string + userDataPath?: string + hostEnv?: NodeJS.ProcessEnv + spawn?: typeof nodeSpawn + entryExists?: (path: string) => boolean + killTimeoutMs?: number +} + +/** Thrown when the host CLI entry cannot be launched at all; callers fall back + * to the legacy in-process command switch so previously-working commands keep + * working even on broken installs. */ +export class HostCliUnavailableError extends Error {} + +// Why: only Orca terminal-context vars may cross from the remote shell into +// the host CLI process. Remote PATH / ORCA_USER_DATA_PATH are paths on the +// remote machine (meaningless or instance-hijacking on the host), and +// NODE_OPTIONS-style vars could alter host execution. +const REMOTE_CONTEXT_ENV_VARS = [ + 'ORCA_TERMINAL_HANDLE', + 'ORCA_WORKTREE_ID', + 'ORCA_PANE_KEY', + 'ORCA_WORKSPACE_ID' +] as const + +// Why: bound captured output so a runaway command cannot balloon the relay +// JSON-RPC response or main-process memory. +const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 +const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000 +const KILL_TIMEOUT_GRACE_MS = 2 * 60_000 + +export function resolveHostCliEntryPath(app: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev + // launcher in cli-installer.ts — packaged builds ship the CLI entry outside + // app.asar so Electron node mode can execute it directly. + return app.isPackaged + ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + : join(app.appPath, 'out', 'cli', 'index.js') +} + +/** Kill timer for the host CLI subprocess. Long-poll commands carry their wait + * budget in `--timeout-ms`; extend past it so the CLI's own timeout fires + * first and produces a proper error message. */ +export function resolveHostCliKillTimeoutMs(argv: string[]): number { + const explicit = parseTimeoutMsFlag(argv) + if (explicit !== null && Number.isFinite(explicit) && explicit > 0) { + return Math.max(DEFAULT_KILL_TIMEOUT_MS, explicit + KILL_TIMEOUT_GRACE_MS) + } + return DEFAULT_KILL_TIMEOUT_MS +} + +export function buildHostCliEnv(args: { + hostEnv: NodeJS.ProcessEnv + remoteEnv: Record + userDataPath: string + remoteCwd: string +}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...args.hostEnv } + for (const key of REMOTE_CONTEXT_ENV_VARS) { + const value = args.remoteEnv[key] + if (typeof value === 'string' && value.length > 0) { + env[key] = value + } + } + // Why: bind the subprocess to this app instance's runtime metadata (dev and + // parallel instances use non-default userData dirs). + env.ORCA_USER_DATA_PATH = args.userDataPath + // Why: the caller's working directory lives on the remote machine, so the + // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for + // cwd-based selectors like `--worktree active`. + env.ORCA_CLI_CWD = args.remoteCwd + // Why: same node-mode hygiene as the shipped CLI launchers — stash and clear + // NODE_OPTIONS so Electron's node bootstrap does not inherit them. + env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' + env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? '' + delete env.NODE_OPTIONS + delete env.NODE_REPL_EXTERNAL_MODULE + env.ELECTRON_RUN_AS_NODE = '1' + return env +} + +export async function runHostOrcaCliPassthrough( + request: RemoteOrcaCliRequest, + options: HostCliPassthroughOptions = {} +): Promise { + // Why: per-field lazy defaults keep the module testable — tests inject all + // three, so no Electron API is touched outside the production path. + const execPath = options.execPath ?? process.execPath + let cliEntryPath: string + let userDataPath: string + try { + cliEntryPath = + options.cliEntryPath ?? + resolveHostCliEntryPath({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }) + // Why: must match the userData dir the runtime RPC server writes metadata + // to (see index.ts OrcaRuntimeRpcServer wiring), or the CLI subprocess + // reports "Orca is not running" against a healthy app. + userDataPath = options.userDataPath ?? getCanonicalUserDataPath() + } catch (err) { + // Why: no Electron app context (or broken install paths) — degrade to the + // caller's legacy in-process fallback instead of failing the command. + throw new HostCliUnavailableError( + `Host CLI environment unavailable: ${err instanceof Error ? err.message : String(err)}` + ) + } + const hostEnv = options.hostEnv ?? process.env + const spawn = options.spawn ?? nodeSpawn + const entryExists = options.entryExists ?? existsSync + const killTimeoutMs = options.killTimeoutMs ?? resolveHostCliKillTimeoutMs(request.argv) + + if (!entryExists(cliEntryPath)) { + throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) + } + + const env = buildHostCliEnv({ + hostEnv, + remoteEnv: request.env, + userDataPath, + remoteCwd: request.cwd + }) + + return await new Promise((resolve, reject) => { + let settled = false + const child = spawn(execPath, [cliEntryPath, ...request.argv], { + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + + const stdout = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) + const stderr = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) + + const killTimer = setTimeout(() => { + if (settled) { + return + } + settled = true + try { + child.kill('SIGKILL') + } catch { + // best effort — process may already be gone + } + resolve({ + stdout: stdout.toString(), + stderr: `${stderr.toString()}Orca CLI bridge timed out after ${killTimeoutMs}ms on the host.\n`, + exitCode: 1 + }) + }, killTimeoutMs) + killTimer.unref?.() + + child.on('error', (err) => { + if (settled) { + return + } + settled = true + clearTimeout(killTimer) + // Why: failure to launch (ENOENT, EACCES) means the host CLI is not + // runnable at all — signal the caller to use the legacy fallback rather + // than reporting a confusing per-command failure. + reject( + new HostCliUnavailableError(`Failed to launch the Orca CLI on the host: ${err.message}`) + ) + }) + + child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)) + child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) + + child.on('close', (code) => { + if (settled) { + return + } + settled = true + clearTimeout(killTimer) + resolve({ + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: typeof code === 'number' ? code : 1 + }) + }) + + if (child.stdin) { + child.stdin.on('error', () => { + // Why: the CLI may exit without draining stdin; EPIPE here is routine. + }) + if (request.stdin !== undefined) { + child.stdin.end(request.stdin) + } else { + child.stdin.end() + } + } + }) +} + +class CappedOutputCollector { + private readonly chunks: Buffer[] = [] + private bytes = 0 + private truncated = false + + constructor(private readonly maxBytes: number) {} + + push(chunk: Buffer): void { + if (this.truncated) { + return + } + const remaining = this.maxBytes - this.bytes + if (chunk.length >= remaining) { + this.chunks.push(chunk.subarray(0, remaining)) + this.bytes = this.maxBytes + this.truncated = true + return + } + this.chunks.push(chunk) + this.bytes += chunk.length + } + + toString(): string { + const text = Buffer.concat(this.chunks).toString('utf8') + return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text + } +} + +function parseTimeoutMsFlag(argv: string[]): number | null { + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (token === '--timeout-ms') { + const next = argv[i + 1] + const parsed = next === undefined ? Number.NaN : Number(next) + return Number.isFinite(parsed) ? parsed : null + } + if (token.startsWith('--timeout-ms=')) { + const parsed = Number(token.slice('--timeout-ms='.length)) + return Number.isFinite(parsed) ? parsed : null + } + } + return null +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index c64443876dd..f54c2157c55 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -1,7 +1,45 @@ +import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +// Why: pointing the passthrough at a missing CLI entry forces the legacy +// in-process fallback, which is what these dispatch tests exercise. +const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false +} + +type FakeChild = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType; on: ReturnType } + kill: ReturnType +} + +function createFakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn(), on: vi.fn() } + child.kill = vi.fn() + return child +} + describe('runRemoteOrcaCli', () => { function createRuntime() { const messages: { @@ -93,11 +131,15 @@ describe('runRemoteOrcaCli', () => { it('uses the remote ORCA_TERMINAL_HANDLE as orchestration sender identity', async () => { const { runtime, db } = createRuntime() - const result = await runRemoteOrcaCli(runtime, { - argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean } @@ -108,18 +150,22 @@ describe('runRemoteOrcaCli', () => { it('accepts equals-style orchestration flags in the remote shim', async () => { const { runtime, db } = createRuntime() - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'orchestration', - 'send', - '--to=term_windows', - '--subject=ping', - '--body=--literal-body', - '--json' - ], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to=term_windows', + '--subject=ping', + '--body=--literal-body', + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean } @@ -138,11 +184,15 @@ describe('runRemoteOrcaCli', () => { body: 'hello' }) - const result = await runRemoteOrcaCli(runtime, { - argv: ['orchestration', 'check', '--all', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'check', '--all', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { @@ -153,4 +203,92 @@ describe('runRemoteOrcaCli', () => { expect(payload.result.count).toBe(1) expect(payload.result.messages[0]?.subject).toBe('pong') }) + + it('routes previously-unsupported commands through the full host CLI', async () => { + const { runtime } = createRuntime() + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runRemoteOrcaCli( + runtime, + { + argv: ['worktree', 'create', '--repo', 'orca', '--branch', 'fix/x', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + spawn: spawn as never + } + ) + + await Promise.resolve() + child.stdout.emit('data', Buffer.from('{"ok":true}\n')) + child.emit('close', 0) + + const result = await resultPromise + expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: '', exitCode: 0 }) + const [, args] = spawn.mock.calls[0] as unknown as [string, string[]] + expect(args).toEqual([ + '/host/app/out/cli/index.js', + 'worktree', + 'create', + '--repo', + 'orca', + '--branch', + 'fix/x', + '--json' + ]) + }) + + it('rejects host-interactive commands with a targeted error instead of bridging them', async () => { + const { runtime } = createRuntime() + const spawn = vi.fn() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['serve'], cwd: '/home/alice', env: {} }, + { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('orca serve') + expect(result.stderr).toContain('SSH relay bridge') + expect(spawn).not.toHaveBeenCalled() + }) + + it('reports host-interactive command errors as JSON envelopes with --json', async () => { + const { runtime } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['serve', '--json'], cwd: '/home/alice', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + const payload = JSON.parse(result.stdout) as { + ok: boolean + error: { code: string } + } + expect(payload.ok).toBe(false) + expect(payload.error.code).toBe('unsupported_over_ssh') + }) + + it('explains the root cause when falling back and the command is not in the legacy switch', async () => { + const { runtime } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['worktree', 'list'], cwd: '/home/alice', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Unsupported SSH Orca CLI command: worktree list') + expect(result.stderr).toContain('full Orca CLI bridge unavailable') + }) }) diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d61fe4f25cf..d555b5c2707 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -3,30 +3,38 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { formatRemoteCli } from './ssh-remote-cli-format' +import { + HostCliUnavailableError, + runHostOrcaCliPassthrough, + type HostCliPassthroughOptions, + type RemoteOrcaCliRequest, + type RemoteOrcaCliResult +} from './ssh-remote-cli-host-passthrough' import { RemoteCliArgumentError, getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' -export type RemoteOrcaCliRequest = { - argv: string[] - cwd: string - env: Record - stdin?: string -} - -export type RemoteOrcaCliResult = { - stdout: string - stderr: string - exitCode: number -} +export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' type ParsedRemoteCli = { commandPath: string[] flags: Map } +// Why: these commands run a foreground/interactive process attached to the +// caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge +// cannot host. Everything else routes through the full host CLI. +const HOST_INTERACTIVE_COMMANDS: Record = { + serve: + 'orca serve starts a foreground headless Orca server and cannot run through the SSH relay bridge. Run it directly on the machine that should host Orca.', + 'claude-teams': + 'orca claude-teams starts an interactive Claude Code session and cannot run through the SSH relay bridge. Run it in a terminal on the Orca host machine.', + 'agent-teams-tmux': + 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.' +} + const REMOTE_BOOLEAN_FLAGS = new Set([ 'all', 'attachments', @@ -48,18 +56,60 @@ const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label']) export async function runRemoteOrcaCli( runtime: OrcaRuntimeService, - request: RemoteOrcaCliRequest + request: RemoteOrcaCliRequest, + passthroughOptions?: HostCliPassthroughOptions ): Promise { - const dispatcher = new RpcDispatcher({ runtime }) const parsed = parseRemoteCliArgs(request.argv) const json = parsed.flags.has('json') + + const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] + if (interactiveMessage) { + if (json) { + return { + stdout: `${JSON.stringify(buildLocalError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, + stderr: '', + exitCode: 1 + } + } + return { stdout: '', stderr: `${interactiveMessage}\n`, exitCode: 1 } + } + + let passthroughFailure: HostCliUnavailableError | null = null + try { + return await runHostOrcaCliPassthrough(request, passthroughOptions) + } catch (err) { + if (!(err instanceof HostCliUnavailableError)) { + throw err + } + // Why: fall back to the legacy in-process command switch below so the + // historical read-only/orchestration surface keeps working even when the + // bundled CLI entry cannot be launched on this install. + passthroughFailure = err + } + return await runLegacyRemoteOrcaCli(runtime, request, parsed, json, passthroughFailure) +} + +async function runLegacyRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: RemoteOrcaCliRequest, + parsed: ParsedRemoteCli, + json: boolean, + passthroughFailure: HostCliUnavailableError +): Promise { + const dispatcher = new RpcDispatcher({ runtime }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } } try { - const response = await dispatchRemoteCli(dispatcher, parsed, request.env, request.stdin) + const response = await dispatchRemoteCli( + dispatcher, + parsed, + request.env, + request.stdin, + passthroughFailure.message + ) const formatted = json ? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' } : formatRemoteCli(response) @@ -93,7 +143,8 @@ async function dispatchRemoteCli( dispatcher: RpcDispatcher, parsed: ParsedRemoteCli, env: Record, - stdin?: string + stdin: string | undefined, + passthroughFailureReason: string ): Promise { const command = parsed.commandPath.join(' ') const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin) @@ -156,7 +207,12 @@ async function dispatchRemoteCli( terminal: optionalString(parsed.flags, 'terminal') }) default: - throw new Error(`Unsupported SSH Orca CLI command: ${command}`) + // Why: only reachable when the full host CLI could not be launched; + // include that root cause so users can fix the install instead of + // assuming the command family is unsupported over SSH. + throw new Error( + `Unsupported SSH Orca CLI command: ${command} (full Orca CLI bridge unavailable: ${passthroughFailureReason})` + ) } } diff --git a/src/relay/remote-cli-env.test.ts b/src/relay/remote-cli-env.test.ts index 844055a832c..1546230a5dd 100644 --- a/src/relay/remote-cli-env.test.ts +++ b/src/relay/remote-cli-env.test.ts @@ -7,6 +7,8 @@ describe('pickRemoteCliEnv', () => { pickRemoteCliEnv({ ORCA_TERMINAL_HANDLE: 'term_ssh', ORCA_WORKTREE_ID: 'repo::remote', + ORCA_PANE_KEY: 'pane-1', + ORCA_WORKSPACE_ID: 'workspace-1', ORCA_USER_DATA_PATH: '/tmp/orca', PATH: '/usr/bin', SECRET_TOKEN: 'nope' @@ -14,6 +16,8 @@ describe('pickRemoteCliEnv', () => { ).toEqual({ ORCA_TERMINAL_HANDLE: 'term_ssh', ORCA_WORKTREE_ID: 'repo::remote', + ORCA_PANE_KEY: 'pane-1', + ORCA_WORKSPACE_ID: 'workspace-1', ORCA_USER_DATA_PATH: '/tmp/orca', PATH: '/usr/bin' }) diff --git a/src/relay/remote-cli-env.ts b/src/relay/remote-cli-env.ts index 360b6074b9a..d8bc800c660 100644 --- a/src/relay/remote-cli-env.ts +++ b/src/relay/remote-cli-env.ts @@ -3,6 +3,8 @@ export function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record for (const key of [ 'ORCA_TERMINAL_HANDLE', 'ORCA_WORKTREE_ID', + 'ORCA_PANE_KEY', + 'ORCA_WORKSPACE_ID', 'ORCA_USER_DATA_PATH', 'PATH', 'Path' diff --git a/src/relay/remote-cli-stdin.test.ts b/src/relay/remote-cli-stdin.test.ts index 62fde790a2f..494ad94e5d5 100644 --- a/src/relay/remote-cli-stdin.test.ts +++ b/src/relay/remote-cli-stdin.test.ts @@ -15,4 +15,16 @@ describe('shouldReadRemoteCliStdin', () => { false ) }) + + it('reads stdin for *-stdin payload flags bridged to the full host CLI', () => { + expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin'])).toBe( + true + ) + expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text', 'hi'])).toBe( + false + ) + expect( + shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin', '--help']) + ).toBe(false) + }) }) diff --git a/src/relay/remote-cli-stdin.ts b/src/relay/remote-cli-stdin.ts index 23ca208978e..f9ab8c56da8 100644 --- a/src/relay/remote-cli-stdin.ts +++ b/src/relay/remote-cli-stdin.ts @@ -2,6 +2,12 @@ export function shouldReadRemoteCliStdin(argv: string[]): boolean { if (argv.includes('--help') || argv.includes('-h')) { return false } + // Why: computer-use style flags (`--text-stdin`, ...) declare a stdin + // payload directly in the flag name; the full-CLI bridge (#7716) must + // forward stdin for them the same way local shells provide it. + if (argv.some((part) => /^--[a-z0-9][a-z0-9-]*-stdin(?:=|$)/.test(part))) { + return true + } const commandPath = parseRemoteCliCommandPath(argv) if (!isLinearBodyWriteCommand(commandPath)) { return false diff --git a/src/relay/remote-cli-timeout.test.ts b/src/relay/remote-cli-timeout.test.ts index 58a62ac5a19..c09a0ccfeaa 100644 --- a/src/relay/remote-cli-timeout.test.ts +++ b/src/relay/remote-cli-timeout.test.ts @@ -2,58 +2,64 @@ import { describe, expect, it } from 'vitest' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' describe('remoteCliRequestTimeoutMs', () => { - it('extends SSH remote CLI timeout for Linear issue context reads', () => { + it('gives Linear issue context reads the general CLI budget', () => { expect( remoteCliRequestTimeoutMs({ argv: ['linear', 'issue', 'ENG-123', '--json'] }) - ).toBe(120_000) + ).toBe(300_000) }) - it('extends the timeout when global flags appear before the Linear command', () => { + it('gives ordinary remote CLI requests the general CLI budget instead of the 30s relay default', () => { + // Why: mutation commands bridged through the full host CLI (worktree + // create, orchestration dispatch, ...) can legitimately exceed 30s. + expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBe(300_000) + expect(remoteCliRequestTimeoutMs({ argv: ['worktree', 'create', '--repo', 'r'] })).toBe(300_000) + }) + + it('extends the timeout for wait-style commands', () => { + expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'wait', '--for', 'exit'] })).toBe(600_000) expect( remoteCliRequestTimeoutMs({ - argv: ['--json', 'linear', 'issue', 'ENG-123', '--workspace', 'workspace-1', '--full'] + argv: ['orchestration', 'check', '--wait', '--json'] }) - ).toBe(120_000) - }) - - it('extends SSH remote CLI timeout for Linear search', () => { + ).toBe(600_000) expect( remoteCliRequestTimeoutMs({ - argv: ['linear', 'search', 'auth', '--limit', '1'] + argv: ['orchestration', 'ask', '--to', 'term_x', '--question', 'ok?'] }) - ).toBe(120_000) + ).toBe(600_000) }) - it('extends the timeout when boolean flags appear between Linear and search', () => { + it('extends past an explicit --timeout-ms waiter budget', () => { expect( remoteCliRequestTimeoutMs({ - argv: ['linear', '--json', 'search', 'auth', '--limit', '1'] + argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '1800000'] }) - ).toBe(120_000) - }) - - it('extends the timeout when boolean flags appear between Linear and issue', () => { + ).toBe(1_860_000) expect( remoteCliRequestTimeoutMs({ - argv: ['linear', '--json', 'issue', 'ENG-123', '--full'] + argv: ['orchestration', 'check', '--wait', '--timeout-ms=1800000'] }) - ).toBe(120_000) + ).toBe(1_860_000) }) - it('extends SSH remote CLI timeout for Linear writes', () => { - for (const argv of [ - ['linear', 'status', 'set', 'ENG-123', '--to', 'Done'], - ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - ['linear', 'attach', 'ENG-123', '--url', 'https://example.invalid/review'], - ['linear', 'create', '--team', 'ENG', '--title', 'Follow up'] - ]) { - expect(remoteCliRequestTimeoutMs({ argv })).toBe(120_000) - } + it('keeps the wait base budget when --timeout-ms is small', () => { + expect( + remoteCliRequestTimeoutMs({ + argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '5000'] + }) + ).toBe(600_000) }) - it('keeps ordinary remote CLI requests on the relay default timeout', () => { - expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBeUndefined() + it('does not treat a flag value named wait as a command path element', () => { + expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'read', '--terminal', 'wait'] })).toBe( + 300_000 + ) + }) + + it('falls back to the relay default for malformed argv', () => { + expect(remoteCliRequestTimeoutMs({ argv: 'status' })).toBeUndefined() + expect(remoteCliRequestTimeoutMs({})).toBeUndefined() }) }) diff --git a/src/relay/remote-cli-timeout.ts b/src/relay/remote-cli-timeout.ts index a7f73e0bf6c..b28ef0ee313 100644 --- a/src/relay/remote-cli-timeout.ts +++ b/src/relay/remote-cli-timeout.ts @@ -1,4 +1,13 @@ -const LINEAR_ISSUE_CONTEXT_TIMEOUT_MS = 120_000 +// Why: the host bridges the full Orca CLI over the relay (#7716), so mutation +// commands (worktree create, orchestration dispatch, Linear writes, ...) can +// legitimately outlive the relay's 30 s default request timeout. Long-poll +// commands carry their waiter budget in --timeout-ms; extend past it so the +// host-side CLI produces its proper timeout error instead of the relay +// cutting the request short. +const REMOTE_CLI_DEFAULT_TIMEOUT_MS = 5 * 60_000 +const REMOTE_CLI_WAIT_TIMEOUT_MS = 10 * 60_000 +const REMOTE_CLI_TIMEOUT_GRACE_MS = 60_000 + const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([ 'all', 'attachments', @@ -15,30 +24,54 @@ const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([ ]) export function remoteCliRequestTimeoutMs(params: Record): number | undefined { - return isLinearCliRequest(params) ? LINEAR_ISSUE_CONTEXT_TIMEOUT_MS : undefined + const argv = getStringArgv(params) + if (!argv) { + return undefined + } + const base = isWaitStyleCliRequest(argv) + ? REMOTE_CLI_WAIT_TIMEOUT_MS + : REMOTE_CLI_DEFAULT_TIMEOUT_MS + const explicit = parseTimeoutMsFlag(argv) + if (explicit !== null && explicit > 0) { + return Math.max(base, explicit + REMOTE_CLI_TIMEOUT_GRACE_MS) + } + return base } -function isLinearCliRequest(params: Record): boolean { - const argv = params.argv - if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) { - return false +function isWaitStyleCliRequest(argv: string[]): boolean { + if (argv.includes('--wait')) { + return true } const commandPath = parseRemoteCommandPath(argv) - return commandPath.some( - (part, index) => - part === 'linear' && isExtendedLinearCliCommand(commandPath.slice(index + 1, index + 4)) + return ( + (commandPath[0] === 'terminal' && commandPath[1] === 'wait') || + (commandPath[0] === 'orchestration' && commandPath[1] === 'ask') ) } -function isExtendedLinearCliCommand(command: string[]): boolean { - const [first, second] = command - if (first === 'issue' || first === 'search' || first === 'attach' || first === 'create') { - return true +function parseTimeoutMsFlag(argv: string[]): number | null { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + let raw: string | undefined + if (token === '--timeout-ms') { + raw = argv[index + 1] + } else if (token.startsWith('--timeout-ms=')) { + raw = token.slice('--timeout-ms='.length) + } else { + continue + } + const parsed = raw === undefined ? Number.NaN : Number(raw) + return Number.isFinite(parsed) ? parsed : null } - if (first === 'status' && second === 'set') { - return true + return null +} + +function getStringArgv(params: Record): string[] | null { + const argv = params.argv + if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) { + return null } - return first === 'comment' && second === 'add' + return argv } function parseRemoteCommandPath(argv: string[]): string[] { From 5a390cd60e81e815d4aaa8b9b8d10c45cb37ee86 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:34:16 -0700 Subject: [PATCH 071/152] Fix Claude Agent Teams detection without Claude CLI (#7834) Co-authored-by: Orca --- src/main/ipc/preflight.test.ts | 74 +++++++++++++++++ src/main/ipc/preflight.ts | 43 +++++----- .../ipc/tui-agent-detection-commands.test.ts | 49 ++++++++++++ src/main/ipc/tui-agent-detection-commands.ts | 77 ++++++++++++++++++ src/relay/preflight-handler.test.ts | 80 +++++++++++++++++++ src/relay/preflight-handler.ts | 47 ++++++++++- src/shared/tui-agent-config.ts | 15 +++- 7 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 src/main/ipc/tui-agent-detection-commands.test.ts create mode 100644 src/main/ipc/tui-agent-detection-commands.ts diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 944ee1b4418..e1f391af79d 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -492,6 +492,58 @@ describe('preflight', () => { await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor']) }) + it('does not report Claude Agent Teams when only the Orca shim is present', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual([]) + }) + + it('reports Claude Agent Teams when both Orca and Claude are present', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/Users/test/.local/bin/claude\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams']) + }) + + it('does not report Claude Agent Teams on native Windows', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'where') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/mock/windows/npm/claude.cmd\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/mock/windows/programs/orca.cmd\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude']) + }) + it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => { // Why: cold GUI launches can run detection before shell-PATH hydration // adds user install dirs, so `which` can miss runnable CLIs. @@ -634,6 +686,28 @@ describe('preflight', () => { expect(hydrateShellPathMock).not.toHaveBeenCalled() }) + it('does not report Claude Agent Teams from WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + expect(script).not.toContain("'orca'") + expect(script).not.toContain("'orca-dev'") + expect(script).not.toContain("'orca-ide'") + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index cc14ca490b6..6a33e112fa2 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getAzureDevOpsAuthStatus } from '../azure-devops/client' @@ -22,6 +21,11 @@ import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities } from './preflight-remote-windows-terminal-capabilities' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' export type PreflightStatus = { git: { installed: boolean } @@ -60,13 +64,6 @@ export function _resetPreflightCache(): void { cached = null } -const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => - getTuiAgentDetectCommands(config).map((cmd) => ({ - id, - cmd - })) -) - function uniqueAgentIds(ids: Iterable): string[] { return [...new Set(ids)] } @@ -92,16 +89,17 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): if (wslTarget) { const foundCommands = await detectWslCommandsOnPath( wslTarget, - KNOWN_AGENT_COMMANDS.map(({ cmd }) => cmd) - ) - return uniqueAgentIds( - KNOWN_AGENT_COMMANDS.filter(({ cmd }) => foundCommands.has(cmd)).map(({ id }) => id) + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') ) + return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') } + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) const pathChecks = await Promise.all( - KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ cmd, installedOnPath: await isCommandOnPath(cmd) })) @@ -110,11 +108,16 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution // computes user install dirs once instead of blocking once per missed CLI. const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const checks = pathChecks.map(({ id, cmd, installedOnPath }) => ({ - id, - installed: installedOnPath || installDirCommands.has(cmd) - })) - return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) } export async function detectInstalledAgentsWithShellPathHydration( @@ -181,7 +184,7 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return [] } const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_AGENT_COMMANDS + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS })) as { agents: string[] } return uniqueAgentIds(result.agents) } diff --git a/src/main/ipc/tui-agent-detection-commands.test.ts b/src/main/ipc/tui-agent-detection-commands.test.ts new file mode 100644 index 00000000000..b6d536f732b --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' + +describe('tui agent detection commands', () => { + it('requires Claude before reporting Claude Agent Teams', () => { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.id === 'claude-agent-teams' + ) + + expect(commands).toEqual([ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-dev', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-ide', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + } + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'linux')).toEqual([ + 'orca', + 'claude', + 'orca-dev', + 'orca-ide' + ]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca']), 'linux')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'linux')).toEqual([ + 'claude-agent-teams' + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'win32')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'win32')).toEqual([]) + expect(getTuiAgentDetectionProbeCommands(commands, 'wsl')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca-ide', 'claude']), 'wsl')).toEqual([]) + }) +}) diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..dc5d5909543 --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from '../../shared/types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from '../../shared/tui-agent-config' + +export type TuiAgentDetectionCommand = { + id: TuiAgent + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] +} + +export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() + +function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { + return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => + getTuiAgentDetectCommands(config).map((cmd) => + buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) + ) + ) +} + +function buildTuiAgentDetectionCommand( + id: TuiAgent, + cmd: string, + config: TuiAgentConfig +): TuiAgentDetectionCommand { + return { + id, + cmd, + ...(config.detectRequiredCommands?.length + ? { requiredCommands: config.detectRequiredCommands } + : {}), + ...(config.detectUnsupportedRuntimes?.length + ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } + : {}) + } +} + +export function getTuiAgentDetectionProbeCommands( + commands: readonly TuiAgentDetectionCommand[], + runtime: TuiAgentDetectionRuntime +): string[] { + return [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] +} + +export function resolveDetectedTuiAgentIds( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime +): TuiAgent[] { + const detected = commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + return [...new Set(detected)] +} + +export function isDetectionUnsupportedInRuntime( + command: TuiAgentDetectionCommand, + runtime: TuiAgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 668354b03db..8833ddcb97f 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -236,6 +236,86 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('honors required commands when reporting detected agents', async () => { + execFileAsyncMock.mockImplementation(async (_file, args) => { + const script = String(args[1]) + if (script.includes("'orca'")) { + return { stdout: '__ORCA_AGENT_PATH__/relay/path/orca\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { id: 'claude-agent-teams', cmd: 'orca', requiredCommands: ['claude'] }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: [] }) + }) + + it('does not report platform-unsupported agents on native Windows SSH hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (_file, args) => { + if (String(args[0]) === 'claude') { + return { stdout: 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd\r\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: 'C:\\Program Files\\Orca\\orca.cmd\r\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + try { + new PreflightHandler(dispatcher as never) + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32'] + }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: ['claude'] }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('reports remote Windows shell capabilities through the SSH preflight path', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 3eff8121368..27e7713343c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -22,6 +22,15 @@ type RelayCommandLookupOptions = { accountLoginShell?: string | null } +type AgentDetectionRuntime = NodeJS.Platform | 'wsl' + +type AgentDetectionCommand = { + id: string + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly AgentDetectionRuntime[] +} + const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' @@ -45,19 +54,42 @@ export class PreflightHandler { // on the relay side. This keeps the relay bundle minimal and makes the protocol // self-describing — the relay doesn't need to know the agent catalog. private async detectAgents(params: Record): Promise<{ agents: string[] }> { - const commands = params.commands as { id: string; cmd: string }[] + const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { return { agents: [] } } + const probeCommands = [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] const results = await Promise.all( - commands.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ + cmd, installed: await this.isCommandOnPath(cmd) })) ) + const foundCommands = new Set( + results.filter((result) => result.installed).map(({ cmd }) => cmd) + ) - return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } + return { + agents: [ + ...new Set( + commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, process.platform) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + ) + ] + } } private async detectWindowsTerminalCapabilities(): Promise<{ @@ -91,6 +123,13 @@ export class PreflightHandler { } } +function isDetectionUnsupportedInRuntime( + command: AgentDetectionCommand, + runtime: AgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} + export function buildCommandLookupSpec( command: string, platform: NodeJS.Platform, diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d15bc8bdb13..fc06ac4912b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -13,10 +13,16 @@ export type DraftPasteReadySignal = | 'codex-composer-prompt' | 'render-cursor-after-bracketed-paste' +export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' + export type TuiAgentConfig = { detectCmd: string /** Additional executable names that identify the same agent on PATH. */ detectCmdAliases?: readonly string[] + /** Other commands that must also be present before this agent counts as installed. */ + detectRequiredCommands?: readonly string[] + /** Detection runtimes where this launch mode is not available as a detected agent. */ + detectUnsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] launchCmd: string /** Platform-specific launch command when the public binary name differs. */ launchCmdByPlatform?: Partial> @@ -71,10 +77,15 @@ export const TUI_AGENT_CONFIG: Record = { }, 'claude-agent-teams': { // Why: this is an Orca-provided launch mode, not a separate upstream - // binary. Detection follows the Orca CLI, while the wrapper validates the - // real Claude binary when it starts. + // binary. Detection follows the Orca CLI and requires Claude below. detectCmd: 'orca', detectCmdAliases: ['orca-dev', 'orca-ide'], + // Why: the Orca shim alone exists on fresh installs. Require Claude too so + // onboarding does not report Agent Teams when no agent CLI is installed. + detectRequiredCommands: ['claude'], + // Why: native Windows and WSL use Claude's in-process Agent Teams fallback, + // not the Orca native-pane/tmux-shim wrapper exposed by this agent entry. + detectUnsupportedRuntimes: ['win32', 'wsl'], launchCmd: 'orca claude-teams', launchCmdByPlatform: { linux: `${getOrcaCliCommandNameForPlatform('linux')} claude-teams`, From e218bf88f6524bc879539a2617fe1ef7ffd80c0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:38:25 -0700 Subject: [PATCH 072/152] Keep your New GitHub issue draft after an accidental dismissal (#7778) --- src/renderer/src/components/TaskPage.tsx | 106 ++++++++++- .../task-page-new-issue-draft.test.ts | 165 ++++++++++++++++++ .../components/task-page-new-issue-draft.ts | 93 ++++++++++ src/renderer/src/store/index.ts | 4 +- .../src/store/slices/diffComments.test.ts | 4 +- .../src/store/slices/new-issue-draft.test.ts | 81 +++++++++ .../src/store/slices/new-issue-draft.ts | 41 +++++ .../src/store/slices/store-test-helpers.ts | 4 +- src/renderer/src/store/types.ts | 4 +- 9 files changed, 490 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/task-page-new-issue-draft.test.ts create mode 100644 src/renderer/src/components/task-page-new-issue-draft.ts create mode 100644 src/renderer/src/store/slices/new-issue-draft.test.ts create mode 100644 src/renderer/src/store/slices/new-issue-draft.ts diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 944fd404f1c..cbb3633d0cf 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -199,6 +199,12 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' +import { + isNewIssueDraftContentful, + resolveNewIssueOpenSeed, + resolveUserRepoSwitchReset, + resolveVanishedNewIssueRepoReset +} from '@/components/task-page-new-issue-draft' import { findTaskPageJiraIssue } from '@/components/task-page-jira-cache-selectors' import { getRepoBackedTaskEmptyState } from '@/components/task-page-empty-state' import { @@ -4052,6 +4058,16 @@ export default function TaskPage(): React.JSX.Element { const [newIssueAssignees, setNewIssueAssignees] = useState([]) const [newIssueSubmitting, setNewIssueSubmitting] = useState(false) const [newIssueRepoId, setNewIssueRepoId] = useState(null) + // Why: session-only draft slice backs recovery of an in-progress issue after + // an accidental dismissal (outside click/Escape/Cancel) and across a Tasks + // view unmount. Component `useState` stays the inputs' immediate source; the + // store is the durable-across-remount backing. See task-page-new-issue-draft. + // The draft value is read imperatively at open time (getState) rather than + // subscribed: it's only consumed in the `+` open handler, and the write-through + // rewrites it on every keystroke — subscribing would re-render all of TaskPage + // per keystroke while the modal is open. Actions are stable refs (no churn). + const setNewIssueDraft = useAppStore((s) => s.setNewIssueDraft) + const clearNewIssueDraft = useAppStore((s) => s.clearNewIssueDraft) // Why: resolve the target repo from the user's choice, falling back to the // first selected repo if the chosen id drops out of the selection while the @@ -4096,10 +4112,62 @@ export default function TaskPage(): React.JSX.Element { { runtimeEnvironmentId: newIssueOpen ? (newIssueRuntimeTarget?.environmentId ?? null) : null } ) + // Why: repo-scoped labels/assignees can't cross repos. A reactive clear keyed + // on the derived target id can't tell a restore apart from a user switch, so + // it would wipe just-restored fields and corrupt the recovery draft via the + // write-through below. Decompose by cause instead: this guard only handles the + // "chosen repo vanished from the selection" case (removed/deselected). A + // genuine user switch clears imperatively in the repo Select's handler; a + // restore always seeds an in-selection repoId, so neither path fires here. useEffect(() => { + const reset = resolveVanishedNewIssueRepoReset( + newIssueRepoId, + selectedRepos.map((r) => r.id) + ) + if (!reset) { + return + } setNewIssueLabels([]) setNewIssueAssignees([]) - }, [newIssueTargetRepo?.id]) + setNewIssueRepoId(reset.repoId) + }, [newIssueRepoId, selectedRepos]) + + // Why: mirror the live fields into the session draft while the modal is open + // so an accidental dismissal doesn't lose input. Content-gate the write so an + // untouched open never pins a meaningless draft (repoId alone is not content), + // and clear any stale draft once the form is emptied back out. + useEffect(() => { + if (!newIssueOpen) { + return + } + if ( + isNewIssueDraftContentful({ + title: newIssueTitle, + body: newIssueBody, + labels: newIssueLabels, + assignees: newIssueAssignees + }) + ) { + setNewIssueDraft({ + title: newIssueTitle, + body: newIssueBody, + labels: newIssueLabels, + assignees: newIssueAssignees, + repoId: newIssueRepoId + }) + } else { + clearNewIssueDraft() + } + }, [ + newIssueOpen, + newIssueTitle, + newIssueBody, + newIssueLabels, + newIssueAssignees, + newIssueRepoId, + setNewIssueDraft, + clearNewIssueDraft + ]) const [selectedLinearIssueId, setSelectedLinearIssueId] = useState(null) const [selectedLinearIssueFallback, setSelectedLinearIssueFallback] = @@ -6633,6 +6701,10 @@ export default function TaskPage(): React.JSX.Element { setNewIssueBody('') setNewIssueLabels([]) setNewIssueAssignees([]) + // Why: a successful submit is the only path that discards the recovery + // draft. Closing `newIssueOpen` in the same commit keeps the write-through + // effect (gated on it) from re-persisting the emptied fields. + clearNewIssueDraft() // Why: bump the nonce so the list refetches and shows the new issue. setTaskRefreshNonce((current) => current + 1) @@ -6700,7 +6772,8 @@ export default function TaskPage(): React.JSX.Element { newIssueTargetRepo, newIssueTitle, openGitHubDetailPage, - setDialogWorkItem + setDialogWorkItem, + clearNewIssueDraft ]) const handleCreateNewLinearProject = useCallback(async (): Promise => { @@ -8234,11 +8307,20 @@ export default function TaskPage(): React.JSX.Element { variant="outline" size="icon" onClick={() => { - setNewIssueTitle('') - setNewIssueBody('') - setNewIssueLabels([]) - setNewIssueAssignees([]) - setNewIssueRepoId(primaryRepo?.id ?? null) + // Why: restore a content-non-empty draft instead + // of resetting, so an accidental dismissal is + // recoverable. The empty-default branch stays live + // so a stale draft never hijacks a fresh open after + // the user changed their primary/selected repo. + const seed = resolveNewIssueOpenSeed({ + draft: useAppStore.getState().newIssueDraft, + selectedRepoIds: selectedRepos.map((r) => r.id) + }) + setNewIssueTitle(seed.title) + setNewIssueBody(seed.body) + setNewIssueLabels(seed.labels) + setNewIssueAssignees(seed.assignees) + setNewIssueRepoId(seed.repoId) setNewIssueOpen(true) }} disabled={!newIssueTargetRepo} @@ -11057,7 +11139,15 @@ export default function TaskPage(): React.JSX.Element { onNameChange(event.target.value)} + placeholder={translate( + 'auto.components.orca.profiles.switcher.f322e1f4d6', + 'Profile name' + )} + maxLength={80} + /> + + + + + + + ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx new file mode 100644 index 00000000000..8f1577c7998 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx @@ -0,0 +1,272 @@ +import { useMemo, useState } from 'react' +import { ArrowRightLeft, Copy, FolderGit2, Loader2, MoveRight } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { ScrollArea } from '@/components/ui/scroll-area' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { + OrcaProfileSummary, + TransferOrcaProfileProjectMode +} from '../../../../shared/orca-profiles' +import type { Repo } from '../../../../shared/types' +import { OrcaProfileAvatar } from './OrcaProfileAvatar' +import { + OrcaProfileProjectTransferConfirmDialog, + type PendingProjectTransfer +} from './OrcaProfileProjectTransferConfirmDialog' +import { getOrcaProfileProjectLiveWorkSummary } from './orca-profile-switch-liveness' + +type PendingTransfer = { + repoId: string + targetProfileId: string + mode: TransferOrcaProfileProjectMode +} + +function pendingKey(value: PendingTransfer): string { + return `${value.mode}:${value.repoId}:${value.targetProfileId}` +} + +function getRepoPath(repo: Repo): string { + return repo.path || repo.displayName +} + +function ProjectTransferMenu({ + repo, + sourceProfileId, + targetProfiles, + pending, + onTransfer +}: { + repo: Repo + sourceProfileId: string + targetProfiles: OrcaProfileSummary[] + pending: PendingTransfer | null + onTransfer: ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ) => void +}): React.JSX.Element { + const disabled = targetProfiles.length === 0 || Boolean(pending) + const repoPending = pending?.repoId === repo.id + const renderTargetItems = (mode: TransferOrcaProfileProjectMode): React.JSX.Element[] => + targetProfiles.map((profile) => { + const targetPending = + pending && + pendingKey(pending) === pendingKey({ repoId: repo.id, targetProfileId: profile.id, mode }) + return ( + onTransfer(repo, profile, mode)} + > + {mode === 'move' ? : } + + {profile.name} + {targetPending ? : null} + + ) + }) + + return ( + + + + + + + {translate('auto.components.orca.profiles.management.128c7dfe64', 'Copy to')} + + {renderTargetItems('copy')} + + + {translate('auto.components.orca.profiles.management.df8b7d876b', 'Move to')} + + {renderTargetItems('move')} + + + ) +} + +export function OrcaProfileManagementDialog({ + open, + onOpenChange, + activeProfile, + profiles +}: { + open: boolean + onOpenChange: (open: boolean) => void + activeProfile: OrcaProfileSummary + profiles: OrcaProfileSummary[] +}): React.JSX.Element { + const repos = useAppStore((s) => s.repos) + const transferProject = useAppStore((s) => s.transferOrcaProfileProject) + const [pending, setPending] = useState(null) + const [pendingConfirmation, setPendingConfirmation] = useState( + null + ) + const targetProfiles = useMemo( + () => profiles.filter((profile) => profile.id !== activeProfile.id), + [activeProfile.id, profiles] + ) + + const runTransfer = async ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ): Promise => { + if (pending) { + return + } + const nextPending = { repoId: repo.id, targetProfileId: targetProfile.id, mode } + setPending(nextPending) + const result = await transferProject({ + sourceProfileId: activeProfile.id, + targetProfileId: targetProfile.id, + repoId: repo.id, + mode + }) + setPending(null) + if (result?.status === 'transferred') { + toast.success( + mode === 'move' + ? translate('auto.components.orca.profiles.management.9aa26347b3', 'Project moved') + : translate('auto.components.orca.profiles.management.816ce624b6', 'Project copied'), + { + description: targetProfile.name + } + ) + } + } + + const handleTransfer = ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ): void => { + if (pending) { + return + } + const liveWorkSummary = getOrcaProfileProjectLiveWorkSummary(useAppStore.getState(), repo.id) + if (mode === 'move' || liveWorkSummary.hasLiveWork) { + setPendingConfirmation({ repo, targetProfile, mode, liveWorkSummary }) + return + } + void runTransfer(repo, targetProfile, mode) + } + + const confirmTransfer = async (): Promise => { + if (!pendingConfirmation) { + return + } + const next = pendingConfirmation + await runTransfer(next.repo, next.targetProfile, next.mode) + setPendingConfirmation(null) + } + + return ( + + + + + {translate('auto.components.orca.profiles.management.2c45bda8d3', 'Manage profiles')} + + + {translate( + 'auto.components.orca.profiles.management.2db945e4a0', + 'Copy or move projects from the active profile to another local profile.' + )} + + + +
+
+ +
+
{activeProfile.name}
+
+ {translate( + repos.length === 1 + ? 'auto.components.orca.profiles.management.projectCountSingular' + : 'auto.components.orca.profiles.management.projectCountPlural', + repos.length === 1 ? '{{count}} project' : '{{count}} projects', + { count: repos.length } + )} +
+
+
+ + {repos.length === 0 ? ( +
+ {translate( + 'auto.components.orca.profiles.management.8668cb2946', + 'No projects in this profile.' + )} +
+ ) : ( +
+ {repos.map((repo) => ( +
+ +
+
{repo.displayName}
+
+ {getRepoPath(repo)} +
+
+ { + handleTransfer(selectedRepo, targetProfile, mode) + }} + /> +
+ ))} +
+ )} +
+
+ + {targetProfiles.length === 0 ? ( +
+ {translate( + 'auto.components.orca.profiles.management.93034915ab', + 'Create another profile before copying projects.' + )} +
+ ) : null} + setPendingConfirmation(null)} + onConfirm={() => { + void confirmTransfer() + }} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx new file mode 100644 index 00000000000..894e21c8481 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import { Loader2, UserMinus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { OrcaOrgMember, OrcaOrgRole } from '../../../../shared/orca-profiles' +import { + ORG_ROLE_OPTIONS, + orgMemberInitials, + orgRoleLabel +} from './orca-profile-org-members-labels' + +export function OrcaProfileOrgMemberRow({ + member, + canManage, + isSelf, + busy, + onChangeRole, + onRemove +}: { + member: OrcaOrgMember + canManage: boolean + isSelf: boolean + busy: boolean + onChangeRole: (role: OrcaOrgRole) => void + onRemove: () => void +}): React.JSX.Element { + const [confirmingRemove, setConfirmingRemove] = useState(false) + // Why: teammates provisioned server-side who never signed into Orca have no + // userId, so the API cannot target them for role/remove mutations. + const neverSignedIn = member.userId === null + const actionsDisabled = !canManage || isSelf || neverSignedIn || busy + const displayName = member.displayName?.trim() || member.email + + const roleControl = canManage ? ( + + ) : ( + {orgRoleLabel(member.role)} + ) + + return ( +
+ + {orgMemberInitials(member.displayName, member.email)} + +
+
{displayName}
+
{member.email}
+
+ + {neverSignedIn && canManage ? ( + + + {roleControl} + + + {translate( + 'auto.components.orca.profiles.org.members.not.signed.in', + "They haven't signed in to Orca yet." + )} + + + ) : ( + roleControl + )} + + {canManage ? ( + confirmingRemove && !actionsDisabled ? ( +
+ + +
+ ) : ( + + ) + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx new file mode 100644 index 00000000000..35e3a07ae90 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { render, screen, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaOrgMembersRoster } from '../../../../shared/orca-profiles' +import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + disabled, + 'aria-label': ariaLabel, + type + }: { + children?: ReactNode + disabled?: boolean + 'aria-label'?: string + type?: 'button' | 'submit' + }) => ( + + ) +})) + +vi.mock('@/components/ui/input', () => ({ + Input: ({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) => ( + + ) +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ children }: { children: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => null, + SelectTrigger: ({ + disabled, + 'aria-label': ariaLabel, + children + }: { + disabled?: boolean + 'aria-label'?: string + children?: ReactNode + }) => ( + + ) +})) + +const managerRoster: OrcaOrgMembersRoster = { + members: [ + { userId: 'user-viewer', email: 'me@example.com', displayName: 'Me', role: 'admin' }, + { userId: 'user-2', email: 'other@example.com', displayName: 'Other', role: 'member' }, + { userId: null, email: 'never@example.com', role: 'member' } + ], + pendingInvites: [{ email: 'pending@example.com', role: 'member', createdAt: 1 }], + viewerRole: 'owner', + canManageMembers: true +} + +const memberRoster: OrcaOrgMembersRoster = { + members: [ + { userId: 'user-1', email: 'boss@example.com', displayName: 'Boss', role: 'owner' }, + { userId: 'user-2', email: 'peer@example.com', displayName: 'Peer', role: 'member' } + ], + pendingInvites: [], + viewerRole: 'member', + canManageMembers: false +} + +function stubOrgMembersApi(roster: OrcaOrgMembersRoster): void { + ;(window as unknown as { api: unknown }).api = { + orcaProfiles: { + orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster }) + } + } +} + +describe('OrcaProfileOrgMembersDialog', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('shows the invite form and a role select per member for managers', async () => { + stubOrgMembersApi(managerRoster) + render( + {}} + orgId="org-1" + orgName="Acme" + viewerUserId="user-viewer" + /> + ) + + expect(await screen.findByText('other@example.com')).toBeTruthy() + // Invite form present. + expect(screen.getByPlaceholderText('teammate@example.com')).toBeTruthy() + // Pending invite listed with a revoke action. + expect(screen.getByText('pending@example.com')).toBeTruthy() + // One role select per member. + expect(screen.getAllByLabelText('Role')).toHaveLength(3) + // Self and the never-signed-in row disable their remove action; the peer's stays enabled. + const removeButtons = screen.getAllByLabelText('Remove teammate') as HTMLButtonElement[] + expect(removeButtons).toHaveLength(3) + expect(removeButtons.filter((button) => button.disabled)).toHaveLength(2) + // Never-signed-in explanation is present. + expect(screen.getByText("They haven't signed in to Orca yet.")).toBeTruthy() + }) + + it('renders a read-only roster for non-managers', async () => { + stubOrgMembersApi(memberRoster) + render( + {}} + orgId="org-1" + viewerUserId="user-2" + /> + ) + + expect(await screen.findByText('boss@example.com')).toBeTruthy() + expect(screen.getByText('peer@example.com')).toBeTruthy() + // No management affordances. + expect(screen.queryByPlaceholderText('teammate@example.com')).toBeNull() + expect(screen.queryAllByLabelText('Role')).toHaveLength(0) + expect(screen.queryAllByLabelText('Remove teammate')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx new file mode 100644 index 00000000000..95c5344ebcb --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loader2, Mail, RefreshCw, Users } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgRole, + OrcaProfileOrgMemberMutationResult +} from '../../../../shared/orca-profiles' +import { OrcaProfileOrgMemberRow } from './OrcaProfileOrgMemberRow' +import { + describeOrgMutationError, + ORG_ROLE_OPTIONS, + orgRoleLabel +} from './orca-profile-org-members-labels' + +type DialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + orgId: string + orgName?: string + viewerUserId?: string +} + +function isOk(result: OrcaProfileOrgMemberMutationResult): result is { status: 'ok' } { + return result.status === 'ok' +} + +export function OrcaProfileOrgMembersDialog({ + open, + onOpenChange, + orgId, + orgName, + viewerUserId +}: DialogProps): React.JSX.Element { + const [roster, setRoster] = useState(null) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(false) + const [actionError, setActionError] = useState(null) + const [pendingMemberId, setPendingMemberId] = useState(null) + const [pendingInviteEmail, setPendingInviteEmail] = useState(null) + const [inviteEmail, setInviteEmail] = useState('') + const [inviteRole, setInviteRole] = useState('member') + const [inviting, setInviting] = useState(false) + + const refresh = useCallback(async (): Promise => { + setLoading(true) + setLoadError(false) + setActionError(null) + const result = await window.api.orcaProfiles.orgMembersList({ orgId }) + if (result.status === 'ok') { + setRoster(result.roster) + } else { + setRoster(null) + setLoadError(true) + } + setLoading(false) + }, [orgId]) + + // Why: reload every time the dialog opens so a manager always acts on the + // current roster, and clear transient dialog state when it closes. + useEffect(() => { + if (open) { + void refresh() + } else { + setActionError(null) + setInviteEmail('') + setInviteRole('member') + } + }, [open, refresh]) + + const canManage = roster?.canManageMembers === true + + const runMutation = async ( + mutation: Promise + ): Promise => { + setActionError(null) + const result = await mutation + if (isOk(result)) { + await refresh() + return true + } + setActionError(describeOrgMutationError(result)) + return false + } + + const handleChangeRole = async (member: OrcaOrgMember, role: OrcaOrgRole): Promise => { + if (member.userId === null || role === member.role) { + return + } + setPendingMemberId(member.userId) + await runMutation( + window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId: member.userId, role }) + ) + setPendingMemberId(null) + } + + const handleRemove = async (member: OrcaOrgMember): Promise => { + if (member.userId === null) { + return + } + setPendingMemberId(member.userId) + await runMutation(window.api.orcaProfiles.orgMemberRemove({ orgId, userId: member.userId })) + setPendingMemberId(null) + } + + const handleRevoke = async (email: string): Promise => { + setPendingInviteEmail(email) + await runMutation(window.api.orcaProfiles.orgInviteRevoke({ orgId, email })) + setPendingInviteEmail(null) + } + + const handleInvite = async (event: React.FormEvent): Promise => { + event.preventDefault() + const email = inviteEmail.trim() + if (!email || inviting) { + return + } + setInviting(true) + const ok = await runMutation( + window.api.orcaProfiles.orgMemberInvite({ orgId, email, role: inviteRole }) + ) + setInviting(false) + if (ok) { + setInviteEmail('') + setInviteRole('member') + } + } + + return ( + + + + + + {translate('auto.components.orca.profiles.org.members.title', 'Organization members')} + + + {orgName + ? translate( + 'auto.components.orca.profiles.org.members.subtitle.named', + 'People in {{orgName}} who can collaborate on Orca.', + { orgName } + ) + : translate( + 'auto.components.orca.profiles.org.members.subtitle.default', + 'People in your organization who can collaborate on Orca.' + )} + + + + {loading ? ( +
+ +
+ ) : loadError ? ( +
+

+ {translate( + 'auto.components.orca.profiles.org.members.load.error', + "Couldn't load organization members." + )} +

+ +
+ ) : roster ? ( +
+ {actionError ? ( +

+ {actionError} +

+ ) : null} + + +
+ {roster.members.map((member) => ( + void handleChangeRole(member, role)} + onRemove={() => void handleRemove(member)} + /> + ))} +
+
+ + {canManage && roster.pendingInvites.length > 0 ? ( +
+

+ {translate( + 'auto.components.orca.profiles.org.members.pending.title', + 'Pending invites' + )} +

+ {roster.pendingInvites.map((invite) => ( +
+ + + {invite.email} + + + {orgRoleLabel(invite.role)} + + +
+ ))} +
+ ) : null} + + {canManage ? ( +
void handleInvite(event)} className="flex items-end gap-2"> +
+ + setInviteEmail(event.target.value)} + disabled={inviting} + placeholder={translate( + 'auto.components.orca.profiles.org.members.invite.placeholder', + 'teammate@example.com' + )} + /> +
+ + +
+ ) : null} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx new file mode 100644 index 00000000000..4b5678e52b1 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx @@ -0,0 +1,162 @@ +import { ArrowRightLeft, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import type { + OrcaProfileSummary, + TransferOrcaProfileProjectMode +} from '../../../../shared/orca-profiles' +import type { Repo } from '../../../../shared/types' +import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' + +type PendingProjectTransfer = { + repo: Repo + targetProfile: OrcaProfileSummary + mode: TransferOrcaProfileProjectMode + liveWorkSummary: OrcaProfileSwitchLiveWorkSummary +} + +export type { PendingProjectTransfer } + +function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { + const lines: string[] = [] + if (summary.liveTerminalTabCount > 0) { + lines.push( + translate( + summary.liveTerminalTabCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural', + summary.liveTerminalTabCount === 1 + ? '{{count}} live terminal tab' + : '{{count}} live terminal tabs', + { count: summary.liveTerminalTabCount } + ) + ) + } + if (summary.liveAgentCount > 0) { + lines.push( + translate( + summary.liveAgentCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.agentPlural', + summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', + { count: summary.liveAgentCount } + ) + ) + } + if (summary.browserWorkspaceCount > 0) { + lines.push( + translate( + summary.browserWorkspaceCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.browserPlural', + summary.browserWorkspaceCount === 1 + ? '{{count}} browser workspace' + : '{{count}} browser workspaces', + { count: summary.browserWorkspaceCount } + ) + ) + } + return lines +} + +export function OrcaProfileProjectTransferConfirmDialog({ + activeProfileName, + pendingTransfer, + pending, + onCancel, + onConfirm +}: { + activeProfileName: string + pendingTransfer: PendingProjectTransfer | null + pending: boolean + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + const mode = pendingTransfer?.mode ?? 'copy' + const repoName = pendingTransfer?.repo.displayName ?? '' + const targetName = pendingTransfer?.targetProfile.name ?? '' + const lines = pendingTransfer ? liveWorkLines(pendingTransfer.liveWorkSummary) : [] + + return ( + { + if (!open && !pending) { + onCancel() + } + }} + > + + + + + {mode === 'move' + ? translate( + 'auto.components.orca.profiles.project.transfer.confirm.move.title', + 'Move project?' + ) + : translate( + 'auto.components.orca.profiles.project.transfer.confirm.copy.title', + 'Copy project?' + )} + + + {mode === 'move' + ? translate( + 'auto.components.orca.profiles.project.transfer.confirm.move.description', + 'Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.', + { activeProfileName, repoName, targetName } + ) + : translate( + 'auto.components.orca.profiles.project.transfer.confirm.copy.description', + 'Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.', + { repoName, targetName } + )} + + + + {lines.length > 0 ? ( +
+
+ {translate( + 'auto.components.orca.profiles.project.transfer.confirm.live.work', + 'Live work in this project' + )} +
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx new file mode 100644 index 00000000000..afa2b4707de --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx @@ -0,0 +1,60 @@ +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +export function OrcaProfileSignOutConfirmDialog({ + open, + onOpenChange, + onConfirm, + profileName, + signingOut +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + profileName: string + signingOut: boolean +}): React.JSX.Element { + return ( + + + + + + {translate('auto.components.orca.profiles.signout.confirm.title', 'Sign out?')} + + + {translate( + 'auto.components.orca.profiles.signout.confirm.description', + 'Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.', + { profileName } + )} + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx new file mode 100644 index 00000000000..89c80e99aa6 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx @@ -0,0 +1,129 @@ +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' +import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' + +function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { + const lines: string[] = [] + if (summary.liveTerminalTabCount > 0) { + lines.push( + translate( + summary.liveTerminalTabCount === 1 + ? 'auto.components.orca.profiles.switch.confirm.terminalSingular' + : 'auto.components.orca.profiles.switch.confirm.terminalPlural', + summary.liveTerminalTabCount === 1 + ? '{{count}} live terminal tab' + : '{{count}} live terminal tabs', + { count: summary.liveTerminalTabCount } + ) + ) + } + if (summary.liveAgentCount > 0) { + lines.push( + translate( + summary.liveAgentCount === 1 + ? 'auto.components.orca.profiles.switch.confirm.agentSingular' + : 'auto.components.orca.profiles.switch.confirm.agentPlural', + summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', + { count: summary.liveAgentCount } + ) + ) + } + if (summary.browserWorkspaceCount > 0) { + lines.push( + translate( + summary.browserWorkspaceCount === 1 + ? 'auto.components.orca.profiles.switch.confirm.browserSingular' + : 'auto.components.orca.profiles.switch.confirm.browserPlural', + summary.browserWorkspaceCount === 1 + ? '{{count}} browser workspace' + : '{{count}} browser workspaces', + { count: summary.browserWorkspaceCount } + ) + ) + } + return lines +} + +export function OrcaProfileSwitchConfirmDialog({ + open, + onOpenChange, + onConfirm, + activeProfileName, + targetProfile, + liveWorkSummary, + switching +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + activeProfileName: string + targetProfile: OrcaProfileSummary | null + liveWorkSummary: OrcaProfileSwitchLiveWorkSummary + switching: boolean +}): React.JSX.Element { + const targetName = + targetProfile?.name ?? + translate('auto.components.orca.profiles.switch.confirm.target', 'the selected profile') + const lines = liveWorkLines(liveWorkSummary) + + return ( + + + + + + {translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')} + + + {translate( + 'auto.components.orca.profiles.switch.confirm.description', + 'Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.', + { activeProfileName, targetName } + )} + + + + {lines.length > 0 ? ( +
+
+ {translate( + 'auto.components.orca.profiles.switch.confirm.live.work', + 'Live work in this profile' + )} +
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx new file mode 100644 index 00000000000..cc52a10c975 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' +import { OrcaProfileSwitcher } from './OrcaProfileSwitcher' + +const mocks = vi.hoisted(() => ({ + state: {} as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(mocks.state) +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + 'aria-label': ariaLabel + }: { + children: ReactNode + 'aria-label'?: string + }) => +})) + +vi.mock('./OrcaProfileAvatar', () => ({ + OrcaProfileAvatar: () => +})) + +vi.mock('./OrcaProfileCreateDialog', () => ({ + OrcaProfileCreateDialog: () =>
+})) + +vi.mock('./OrcaProfileManagementDialog', () => ({ + OrcaProfileManagementDialog: () =>
+})) + +vi.mock('./OrcaProfileSwitchConfirmDialog', () => ({ + OrcaProfileSwitchConfirmDialog: () =>
+})) + +vi.mock('./OrcaProfileSignOutConfirmDialog', () => ({ + OrcaProfileSignOutConfirmDialog: () =>
+})) + +vi.mock('./orca-profile-switch-liveness', () => ({ + getOrcaProfileSwitchLiveWorkSummary: () => ({ hasLiveWork: false }) +})) + +const cloudProfile: OrcaProfileSummary = { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'cloud-linked', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1, + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-1', + activeOrgName: 'Acme', + linkedAt: 2 + } +} + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: cloudProfile.cloud, + organizations: [ + { orgId: 'org-1', name: 'Acme' }, + { orgId: 'org-2', name: 'Globex' } + ] +} + +const unconfiguredAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +function baseState(overrides: Partial): Partial { + return { + orcaProfiles: [cloudProfile], + activeOrcaProfileId: 'local-default', + orcaProfilesLoading: false, + orcaProfileSwitching: false, + orcaProfileConnecting: false, + orcaProfileAuthStatus: connectedAuthStatus, + orcaProfilesMultiProfileUi: false, + fetchOrcaProfiles: vi.fn(), + createLocalOrcaProfile: vi.fn(), + createCloudLinkedOrcaProfile: vi.fn(), + connectCurrentOrcaProfile: vi.fn(), + signOutCurrentOrcaProfile: vi.fn(), + selectOrcaProfileOrg: vi.fn(), + switchOrcaProfile: vi.fn(), + ...overrides + } +} + +describe('OrcaProfileSwitcher', () => { + beforeEach(() => { + mocks.state = baseState({}) + }) + + it('renders an account menu without profile management when the flag is off and cloud is configured', () => { + mocks.state = baseState({ orcaProfilesMultiProfileUi: false }) + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="Account"') + // Cloud actions stay reachable in the downscoped account menu. + expect(html).toContain('Sign out') + // Profile management surfaces are gone. + expect(html).not.toContain('Manage profiles') + expect(html).not.toContain('New local profile') + expect(html).not.toContain('Create profile for org') + expect(html).not.toContain('data-testid="create-dialog"') + expect(html).not.toContain('data-testid="management-dialog"') + expect(html).not.toContain('data-testid="switch-confirm-dialog"') + // Sign-out remains mounted. + expect(html).toContain('data-testid="signout-confirm-dialog"') + }) + + it('renders nothing when the flag is off and cloud is unconfigured', () => { + mocks.state = baseState({ + orcaProfilesMultiProfileUi: false, + orcaProfileAuthStatus: unconfiguredAuthStatus + }) + const html = renderToStaticMarkup() + + expect(html).toBe('') + }) + + it('renders the full multi-profile menu when the flag is on', () => { + mocks.state = baseState({ orcaProfilesMultiProfileUi: true }) + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="Switch profile"') + expect(html).toContain('Manage profiles') + expect(html).toContain('New local profile') + expect(html).toContain('Create profile for org') + expect(html).toContain('data-testid="create-dialog"') + expect(html).toContain('data-testid="management-dialog"') + expect(html).toContain('data-testid="switch-confirm-dialog"') + }) +}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx new file mode 100644 index 00000000000..f8a98da9d0a --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx @@ -0,0 +1,403 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Cloud, Laptop, Loader2, Plus, Settings2, Users } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { OrcaCloudOrgSummary, OrcaProfileSummary } from '../../../../shared/orca-profiles' +import { OrcaProfileAvatar } from './OrcaProfileAvatar' +import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems' +import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog' +import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' +import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog' +import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' +import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog' +import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' + +function isWebClient(): boolean { + return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + +function getProfileSubtitle(profile: OrcaProfileSummary): string { + if (profile.cloud?.activeOrgName) { + return profile.cloud.activeOrgName + } + if (profile.cloud?.email) { + return profile.cloud.email + } + return translate('auto.components.orca.profiles.switcher.b4f9d1125d', 'Local') +} + +export function OrcaProfileSwitcher({ + placement = 'titlebar' +}: { + placement?: 'titlebar' | 'sidebar' +}): React.JSX.Element | null { + const profiles = useAppStore((s) => s.orcaProfiles) + const activeProfileId = useAppStore((s) => s.activeOrcaProfileId) + const loading = useAppStore((s) => s.orcaProfilesLoading) + const switching = useAppStore((s) => s.orcaProfileSwitching) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const multiProfileUi = useAppStore((s) => s.orcaProfilesMultiProfileUi) + const fetchProfiles = useAppStore((s) => s.fetchOrcaProfiles) + const createLocalProfile = useAppStore((s) => s.createLocalOrcaProfile) + const createCloudLinkedProfile = useAppStore((s) => s.createCloudLinkedOrcaProfile) + const connectCurrentProfile = useAppStore((s) => s.connectCurrentOrcaProfile) + const signOutCurrentProfile = useAppStore((s) => s.signOutCurrentOrcaProfile) + const selectOrg = useAppStore((s) => s.selectOrcaProfileOrg) + const switchProfile = useAppStore((s) => s.switchOrcaProfile) + const liveWorkSummary = useAppStore(useShallow((s) => getOrcaProfileSwitchLiveWorkSummary(s))) + const [dialogOpen, setDialogOpen] = useState(false) + const [managementOpen, setManagementOpen] = useState(false) + const [newProfileName, setNewProfileName] = useState('') + const [creating, setCreating] = useState(false) + const [creatingCloudProfile, setCreatingCloudProfile] = useState(false) + const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false) + const [signingOut, setSigningOut] = useState(false) + const [orgMembersOpen, setOrgMembersOpen] = useState(false) + const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState(null) + const activeProfile = useMemo( + () => profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null, + [activeProfileId, profiles] + ) + const pendingSwitchProfile = useMemo( + () => profiles.find((profile) => profile.id === pendingSwitchProfileId) ?? null, + [pendingSwitchProfileId, profiles] + ) + + // Why: one attempt per mount — retrying on every loading toggle would spin + // an unbounded IPC loop when the list call persistently fails. + const fetchAttemptedRef = useRef(false) + useEffect(() => { + if (profiles.length === 0 && !loading && !fetchAttemptedRef.current) { + fetchAttemptedRef.current = true + void fetchProfiles() + } + }, [fetchProfiles, loading, profiles.length]) + + // Why: the Orca Cloud account UX isn't ready for production users yet, so the + // trigger stays hidden in packaged builds. Dev builds still show it when + // cloud auth is configured, and a dev-only Settings > Dev Tools > Orca Cloud + // section mirrors it. import.meta.env.PROD is true only in packaged builds + // (false in dev and under vitest), so tests still exercise the render path. + if (import.meta.env.PROD) { + return null + } + + // Why: paired web/mobile clients only see the desktop stub's fabricated + // profile list; showing a switcher there would misreport the active profile + // and none of its actions can work remotely. + if (isWebClient() || !activeProfile) { + return null + } + + // Why: with multi-profile UI downscoped, local-only builds (no cloud + // configured) have nothing to offer in an account menu — show no trigger. + if (!multiProfileUi && authStatus?.configured !== true) { + return null + } + + const handleCreateProfile = async (event: React.FormEvent): Promise => { + event.preventDefault() + if (creating || switching) { + return + } + setCreating(true) + const profile = await createLocalProfile(newProfileName) + setCreating(false) + if (!profile) { + return + } + setNewProfileName('') + setDialogOpen(false) + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(profile.id) + return + } + await switchProfile(profile.id) + } + + const handleSwitchProfile = (profileId: string): void => { + if (profileId === activeProfileId || switching) { + return + } + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(profileId) + return + } + void switchProfile(profileId) + } + + const handleConfirmSwitchProfile = (): void => { + if (!pendingSwitchProfileId || switching) { + return + } + void switchProfile(pendingSwitchProfileId) + } + + const handleCreateCloudProfileForOrg = async ( + organization: OrcaCloudOrgSummary + ): Promise => { + if (creatingCloudProfile || switching) { + return + } + setCreatingCloudProfile(true) + const result = await createCloudLinkedProfile({ + orgId: organization.orgId, + name: organization.name + }) + setCreatingCloudProfile(false) + if (result?.status !== 'created') { + return + } + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(result.profile.id) + return + } + await switchProfile(result.profile.id) + } + + const handleConfirmSignOut = async (): Promise => { + if (signingOut) { + return + } + setSigningOut(true) + const result = await signOutCurrentProfile() + setSigningOut(false) + if (result) { + setSignOutConfirmOpen(false) + } + } + + const profileActionDisabled = + switching || creating || creatingCloudProfile || connecting || signingOut + // Why: teammate management needs a connected cloud profile scoped to an org; + // the server enforces role permissions, and the dialog adapts via + // canManageMembers, so cloud-linked + org + connected is enough to reveal it. + const activeOrgId = activeProfile.cloud?.activeOrgId + const showOrgMembers = + activeProfile.kind === 'cloud-linked' && + Boolean(activeOrgId) && + authStatus?.state === 'connected' + const sidebarPlacement = placement === 'sidebar' + const triggerLabel = multiProfileUi + ? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile') + : translate('auto.components.orca.profiles.switcher.account', 'Account') + + return ( + <> + + + + + + + + + {triggerLabel} + + + + +
+ +
+
+ {activeProfile.name} +
+
+ {getProfileSubtitle(activeProfile)} +
+
+
+
+ + {multiProfileUi + ? profiles.map((profile) => { + const active = profile.id === activeProfileId + return ( + handleSwitchProfile(profile.id)} + className="min-w-0" + > + + + {profile.name} + + {getProfileSubtitle(profile)} + + + {profile.kind === 'cloud-linked' ? : } + {active && } + + ) + }) + : null} + {showOrgMembers ? ( + <> + + setOrgMembersOpen(true)} + > + + {translate( + 'auto.components.orca.profiles.switcher.org.members', + 'Organization members' + )} + + + ) : null} + { + void connectCurrentProfile() + }} + onCreateProfileForOrg={(organization) => { + void handleCreateCloudProfileForOrg(organization) + }} + onSelectOrg={(orgId) => { + void selectOrg(orgId) + }} + onRequestSignOut={() => setSignOutConfirmOpen(true)} + /> + {multiProfileUi ? ( + <> + + { + setManagementOpen(true) + }} + > + + {translate('auto.components.orca.profiles.switcher.d00d853e2a', 'Manage profiles')} + + { + setDialogOpen(true) + }} + > + + {translate( + 'auto.components.orca.profiles.switcher.c106c674fe', + 'New local profile' + )} + + + ) : null} +
+
+ + {multiProfileUi ? ( + <> + + + + ) : null} + {showOrgMembers && activeOrgId ? ( + + ) : null} + { + if (!signingOut) { + setSignOutConfirmOpen(open) + } + }} + onConfirm={() => { + void handleConfirmSignOut() + }} + profileName={activeProfile.name} + signingOut={signingOut} + /> + {multiProfileUi ? ( + { + if (!open && !switching) { + setPendingSwitchProfileId(null) + } + }} + onConfirm={handleConfirmSwitchProfile} + activeProfileName={activeProfile.name} + targetProfile={pendingSwitchProfile} + liveWorkSummary={liveWorkSummary} + switching={switching} + /> + ) : null} + + ) +} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts new file mode 100644 index 00000000000..9378d77cec1 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts @@ -0,0 +1,79 @@ +import { translate } from '@/i18n/i18n' +import type { + OrcaOrgRole, + OrcaProfileOrgMemberMutationResult +} from '../../../../shared/orca-profiles' + +export const ORG_ROLE_OPTIONS: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] + +export function orgRoleLabel(role: OrcaOrgRole): string { + switch (role) { + case 'owner': + return translate('auto.components.orca.profiles.org.members.role.owner', 'Owner') + case 'admin': + return translate('auto.components.orca.profiles.org.members.role.admin', 'Admin') + case 'member': + return translate('auto.components.orca.profiles.org.members.role.member', 'Member') + } +} + +export function orgMemberInitials(displayName: string | undefined, email: string): string { + const source = displayName?.trim() || email.trim() + if (!source) { + return '?' + } + const words = source.split(/\s+/).filter(Boolean) + if (words.length >= 2) { + return `${words[0][0]}${words[1][0]}`.toUpperCase() + } + return source.slice(0, 2).toUpperCase() +} + +// Why: the dialog maps every non-ok mutation status to a precise, translated +// message so managers see why an action was rejected instead of a raw code. +export function describeOrgMutationError( + result: Exclude +): string { + switch (result.status) { + case 'forbidden': + return translate( + 'auto.components.orca.profiles.org.members.error.forbidden', + "Your role can't do that." + ) + case 'conflict': + return translate( + 'auto.components.orca.profiles.org.members.error.conflict', + 'Already a member or invited.' + ) + case 'invalid': + return result.reason === 'cannot_remove_self' + ? translate( + 'auto.components.orca.profiles.org.members.error.remove.self', + "You can't remove yourself." + ) + : translate( + 'auto.components.orca.profiles.org.members.error.own.role', + "You can't change your own role." + ) + case 'not-found': + return translate( + 'auto.components.orca.profiles.org.members.error.not.found', + 'That teammate is no longer in this organization.' + ) + case 'reconnect-required': + return translate( + 'auto.components.orca.profiles.org.members.error.reconnect', + 'Reconnect your profile to manage members.' + ) + case 'unconfigured': + return translate( + 'auto.components.orca.profiles.org.members.error.unconfigured', + 'Organization management is unavailable in this build.' + ) + case 'failed': + return translate( + 'auto.components.orca.profiles.org.members.error.failed', + 'Something went wrong. Please try again.' + ) + } +} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts new file mode 100644 index 00000000000..1dfc0873727 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' +import { + getOrcaProfileProjectLiveWorkSummary, + getOrcaProfileSwitchLiveWorkSummary, + type OrcaProfileSwitchLiveWorkState +} from './orca-profile-switch-liveness' + +const NOW = 1_000_000 + +function makeState( + overrides: Partial = {} +): OrcaProfileSwitchLiveWorkState { + return { + agentStatusByPaneKey: {}, + browserTabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {}, + ...overrides + } +} + +function makeTab( + overrides: Partial> & { + id: string + worktreeId?: string + } +): TerminalTab { + const { id, worktreeId = 'worktree-1', ...rest } = overrides + return { + id, + color: null, + createdAt: NOW, + customTitle: null, + ptyId: null, + sortOrder: 0, + title: 'zsh', + worktreeId, + ...rest + } +} + +function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:0', + prompt: '', + state: 'working', + stateHistory: [], + stateStartedAt: NOW, + updatedAt: NOW, + ...overrides + } +} + +function makeBrowserWorkspace(id: string): BrowserWorkspace { + return { + id, + activePageId: null, + canGoBack: false, + canGoForward: false, + createdAt: NOW, + faviconUrl: null, + label: 'Browser', + loadError: null, + loading: false, + pageIds: [], + title: 'Browser', + url: 'about:blank', + worktreeId: 'worktree-1' + } +} + +describe('getOrcaProfileSwitchLiveWorkSummary', () => { + it('reports quiet profiles as safe to switch without confirmation', () => { + expect(getOrcaProfileSwitchLiveWorkSummary(makeState(), NOW)).toEqual({ + browserWorkspaceCount: 0, + hasLiveWork: false, + liveAgentCount: 0, + livePtyCount: 0, + liveTerminalTabCount: 0 + }) + }) + + it('counts live PTYs as live terminal work', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + ptyIdsByTabId: { + 'tab-1': ['pty-1', 'pty-2'], + 'tab-2': [] + } + }), + NOW + ) + + expect(summary).toMatchObject({ + hasLiveWork: true, + livePtyCount: 2, + liveTerminalTabCount: 1 + }) + }) + + it('counts fresh working explicit agent status', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + agentStatusByPaneKey: { + 'tab-1:0': makeAgentEntry({ paneKey: 'tab-1:0', state: 'working' }), + 'tab-2:0': makeAgentEntry({ paneKey: 'tab-2:0', state: 'done' }), + 'tab-3:0': makeAgentEntry({ + paneKey: 'tab-3:0', + state: 'waiting', + updatedAt: -999_999_999 + }) + } + }), + NOW + ) + + expect(summary.liveAgentCount).toBe(1) + expect(summary.hasLiveWork).toBe(true) + }) + + it('counts title-detected agents only when the tab has a live PTY', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + ptyIdsByTabId: { + 'tab-live': ['pty-1'] + }, + tabsByWorktree: { + 'worktree-1': [ + makeTab({ id: 'tab-live', title: 'Codex working' }), + makeTab({ id: 'tab-slept', title: 'Codex working' }) + ] + } + }), + NOW + ) + + expect(summary.liveAgentCount).toBe(1) + expect(summary.hasLiveWork).toBe(true) + }) + + it('counts browser workspaces as live browser work', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + browserTabsByWorktree: { + 'worktree-1': [makeBrowserWorkspace('browser-1'), makeBrowserWorkspace('browser-2')] + } + }), + NOW + ) + + expect(summary).toMatchObject({ + browserWorkspaceCount: 2, + hasLiveWork: true + }) + }) + + it('filters live work to the selected project', () => { + const summary = getOrcaProfileProjectLiveWorkSummary( + makeState({ + agentStatusByPaneKey: { + 'other-tab:0': makeAgentEntry({ + paneKey: 'other-tab:0', + worktreeId: 'repo-other::/workspace/other' + }) + }, + browserTabsByWorktree: { + 'repo-1::/workspace/orca': [makeBrowserWorkspace('browser-1')], + 'repo-other::/workspace/other': [makeBrowserWorkspace('browser-2')] + }, + ptyIdsByTabId: { + 'tab-live': ['pty-1'], + 'other-tab': ['pty-2'] + }, + tabsByWorktree: { + 'repo-1::/workspace/orca': [makeTab({ id: 'tab-live', title: 'Codex working' })], + 'repo-other::/workspace/other': [makeTab({ id: 'other-tab', title: 'Codex working' })] + } + }), + 'repo-1', + NOW + ) + + expect(summary).toMatchObject({ + browserWorkspaceCount: 1, + hasLiveWork: true, + liveAgentCount: 1, + livePtyCount: 1, + liveTerminalTabCount: 1 + }) + }) +}) diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts new file mode 100644 index 00000000000..c96a1812157 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts @@ -0,0 +1,180 @@ +import type { AppState } from '@/store/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' + +export type OrcaProfileSwitchLiveWorkState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'browserTabsByWorktree' + | 'ptyIdsByTabId' + | 'runtimePaneTitlesByTabId' + | 'tabsByWorktree' +> + +export type OrcaProfileSwitchLiveWorkSummary = { + hasLiveWork: boolean + liveAgentCount: number + livePtyCount: number + liveTerminalTabCount: number + browserWorkspaceCount: number +} + +const LIVE_AGENT_STATES = new Set(['working', 'blocked', 'waiting']) + +export function getOrcaProfileSwitchLiveWorkSummary( + state: OrcaProfileSwitchLiveWorkState, + now = Date.now() +): OrcaProfileSwitchLiveWorkSummary { + const terminalSummary = getLiveTerminalSummary(state) + const liveAgentCount = getLiveAgentKeys(state, now).size + const browserWorkspaceCount = Object.values(state.browserTabsByWorktree).reduce( + (count, workspaces) => count + workspaces.length, + 0 + ) + + return { + hasLiveWork: + terminalSummary.livePtyCount > 0 || liveAgentCount > 0 || browserWorkspaceCount > 0, + liveAgentCount, + livePtyCount: terminalSummary.livePtyCount, + liveTerminalTabCount: terminalSummary.liveTerminalTabCount, + browserWorkspaceCount + } +} + +export function getOrcaProfileProjectLiveWorkSummary( + state: OrcaProfileSwitchLiveWorkState, + repoId: string, + now = Date.now() +): OrcaProfileSwitchLiveWorkSummary { + const tabsByWorktree = Object.fromEntries( + Object.entries(state.tabsByWorktree).filter(([worktreeId]) => + worktreeBelongsToRepo(worktreeId, repoId) + ) + ) + const tabIds = new Set(Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))) + const browserTabsByWorktree = Object.fromEntries( + Object.entries(state.browserTabsByWorktree).filter(([worktreeId]) => + worktreeBelongsToRepo(worktreeId, repoId) + ) + ) + const ptyIdsByTabId = Object.fromEntries( + Object.entries(state.ptyIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + const runtimePaneTitlesByTabId = Object.fromEntries( + Object.entries(state.runtimePaneTitlesByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + const agentStatusByPaneKey = Object.fromEntries( + Object.entries(state.agentStatusByPaneKey).filter(([paneKey, entry]) => + agentEntryBelongsToProject(paneKey, entry, repoId, tabIds) + ) + ) + + return getOrcaProfileSwitchLiveWorkSummary( + { + agentStatusByPaneKey, + browserTabsByWorktree, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + tabsByWorktree + }, + now + ) +} + +function getLiveTerminalSummary(state: OrcaProfileSwitchLiveWorkState): { + livePtyCount: number + liveTerminalTabCount: number +} { + const liveTabIds = new Set() + let livePtyCount = 0 + + for (const [tabId, ptyIds] of Object.entries(state.ptyIdsByTabId)) { + if (ptyIds.length === 0) { + continue + } + livePtyCount += ptyIds.length + liveTabIds.add(tabId) + } + + return { + livePtyCount, + liveTerminalTabCount: liveTabIds.size + } +} + +function getLiveAgentKeys(state: OrcaProfileSwitchLiveWorkState, now: number): Set { + const keys = new Set() + for (const entry of Object.values(state.agentStatusByPaneKey)) { + if ( + LIVE_AGENT_STATES.has(entry.state) && + isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { + keys.add(entry.paneKey) + } + } + + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + addTitleDetectedLiveAgentKeys(keys, state, tab) + } + } + + return keys +} + +function addTitleDetectedLiveAgentKeys( + keys: Set, + state: OrcaProfileSwitchLiveWorkState, + tab: TerminalTab +): void { + if (!tabHasLivePty(state.ptyIdsByTabId, tab.id)) { + return + } + + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + if (paneTitles && Object.keys(paneTitles).length > 0) { + for (const [paneId, title] of Object.entries(paneTitles)) { + if (isLiveAgentTitle(title)) { + keys.add(`${tab.id}:${paneId}`) + } + } + return + } + + if (isLiveAgentTitle(tab.title)) { + keys.add(`${tab.id}:title`) + } +} + +function isLiveAgentTitle(title: string): boolean { + const status = detectAgentStatusFromTitle(title) + return status === 'working' || status === 'permission' +} + +function agentEntryBelongsToProject( + paneKey: string, + entry: AgentStatusEntry, + repoId: string, + tabIds: Set +): boolean { + if (entry.worktreeId && worktreeBelongsToRepo(entry.worktreeId, repoId)) { + return true + } + return tabIds.has(getPaneKeyTabId(paneKey)) +} + +function getPaneKeyTabId(paneKey: string): string { + const separatorIndex = paneKey.lastIndexOf(':') + return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) +} + +function worktreeBelongsToRepo(worktreeId: string, repoId: string): boolean { + return getRepoIdFromWorktreeId(worktreeId) === repoId +} diff --git a/src/renderer/src/components/settings/DevToolsPane.tsx b/src/renderer/src/components/settings/DevToolsPane.tsx index f22b5823e00..e1779e9aead 100644 --- a/src/renderer/src/components/settings/DevToolsPane.tsx +++ b/src/renderer/src/components/settings/DevToolsPane.tsx @@ -6,6 +6,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { showDeleteWorktreeFailureToast } from '../sidebar/delete-worktree-failure-toast' import { showLocalBaseRefUpdateSuggestionToast } from '../sidebar/local-base-ref-suggestion-toast' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' import type { AppState } from '@/store/types' const LONG_WORKSPACE_NAME = 'feature/dev-toast-layout-with-a-long-workspace-name' @@ -124,6 +125,85 @@ function showDeleteFailureToast(): void { }) } +// Dev-only preview of the first-party Orca Cloud sign-in. The sidebar/titlebar +// account switcher is hidden in packaged builds while the feature is in +// progress; this surfaces it (and its status) in dev when the env vars are set. +function OrcaCloudDevSubsection(): React.JSX.Element { + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const connect = useAppStore((s) => s.connectCurrentOrcaProfile) + const signOut = useAppStore((s) => s.signOutCurrentOrcaProfile) + const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus) + const configured = authStatus?.configured === true + const connected = authStatus?.state === 'connected' + + return ( +
+
+
+ + {configured ? ( +
+

+ {translate('auto.components.settings.DevToolsPane.orcaCloudStatus', 'Status')}:{' '} + {authStatus?.state} +

+
+ {connected ? ( + + ) : ( + + )} + +
+
+ ) : ( +

+ {authStatus?.setupMessage ?? + translate( + 'auto.components.settings.DevToolsPane.orcaCloudNotConfigured', + 'Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.' + )} +

+ )} +
+ ) +} + export function DevToolsPane(): React.JSX.Element { const actions: DevToastAction[] = [ { @@ -221,28 +301,32 @@ export function DevToolsPane(): React.JSX.Element { ] return ( -
-
- - - {translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')} - -
+
+
+
+ + + {translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')} + +
-
- {actions.map((action) => ( - - ))} -
-
+
+ {actions.map((action) => ( + + ))} +
+
+ + +
) } diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx index b3ca7aa3e20..28fd8cbcdcf 100644 --- a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -33,6 +33,9 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie 'Command template' ) } + // Fail at compile time if the override-field union grows a new variant. + const _exhaustive: never = field + return _exhaustive } function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string { diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx index a9d141e9237..f3dde9621ea 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -36,6 +36,14 @@ vi.mock('./SidebarSettingsHelpMenu', () => ({ SidebarSettingsHelpMenu: () => })) +vi.mock('../orca-profiles/OrcaProfileSwitcher', () => ({ + OrcaProfileSwitcher: ({ placement }: { placement?: string }) => ( + + ) +})) + const roots: Root[] = [] async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{ @@ -122,4 +130,12 @@ describe('SidebarToolbar moved workspace board hint', () => { expect(container.textContent).toContain('Workspace board moved to the bottom bar') expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') }) + + it('renders the profile switcher before settings in the footer controls', async () => { + const { container } = await renderToolbar() + const html = container.innerHTML + + expect(html).toContain('data-placement="sidebar"') + expect(html.indexOf('Profile')).toBeLessThan(html.indexOf('Settings')) + }) }) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 9ea74af8d77..ff2e030c7a5 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' +import { OrcaProfileSwitcher } from '../orca-profiles/OrcaProfileSwitcher' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { hasFeatureInteraction } from '../../../../shared/feature-interactions' @@ -65,7 +66,10 @@ const SidebarToolbar = React.memo(function SidebarToolbar({ return (
- +
+ + +
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 62628f70dca..570c20c6c54 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "Project already added", "6d3318e813": "Failed to import repositories", "3be0f7df04": "Cannot open folder on selected runtime", - "15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder." + "15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.", + "2dcd706774": "Project also exists in another profile", + "presenceProfileOverflow": "{{names}} +{{count}} more" }, "settings": { "e12dab333b": "Failed to switch servers", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "Linear connection was superseded by a newer request." + }, + "orca": { + "profiles": { + "612f7f6861": "Failed to create profile", + "319d7cf39b": "Cloud profile created", + "d6e764e7db": "Reconnect this profile", + "f0c9e11a6d": "Failed to create cloud profile", + "8b8fa73174": "Orca Cloud sign-in is not configured", + "33290e88ed": "Failed to connect profile", + "9fcb07a796": "Profile connected", + "2f6c78a039": "Failed to refresh profile auth", + "a37b5e6d37": "Signed out of profile", + "83600521e7": "Failed to sign out", + "76deec8f58": "Failed to switch organization", + "7d4bc516ee": "Failed to switch profile", + "f518e89aa5": "Project already exists in that profile", + "f03ae7f27b": "Failed to transfer project" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "Use in workspace" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "Copy" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "Cloud sign-in unavailable" + }, + "account": "Account", + "reconnect": "Reconnect profile", + "connect": "Connect profile", + "organization": "Organization", + "create": { + "profile": { + "for": { + "org": "Create profile for org" + } + } + }, + "signout": "Sign out", + "16e3681072": "New local profile", + "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", + "f322e1f4d6": "Profile name", + "cfa59f8ad1": "Create and Switch", + "b4f9d1125d": "Local", + "4815f7d163": "Switch profile", + "d00d853e2a": "Manage profiles", + "c106c674fe": "New local profile", + "org": { + "members": "Organization members" + } + }, + "management": { + "04e7bd2a23": "Transfer", + "128c7dfe64": "Copy to", + "df8b7d876b": "Move to", + "9aa26347b3": "Project moved", + "816ce624b6": "Project copied", + "2c45bda8d3": "Manage profiles", + "2db945e4a0": "Copy or move projects from the active profile to another local profile.", + "8668cb2946": "No projects in this profile.", + "93034915ab": "Create another profile before copying projects.", + "projectCountSingular": "{{count}} project", + "projectCountPlural": "{{count}} projects" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "Move project?", + "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", + "action": "Move project" + }, + "copy": { + "title": "Copy project?", + "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", + "action": "Copy project" + }, + "live": { + "work": "Live work in this project" + }, + "cancel": "Cancel", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" + } + } + }, + "signout": { + "confirm": { + "title": "Sign out?", + "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", + "cancel": "Cancel", + "action": "Sign out" + } + }, + "switch": { + "confirm": { + "target": "the selected profile", + "title": "Switch profiles?", + "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "live": { + "work": "Live work in this profile" + }, + "cancel": "Cancel", + "switch": "Switch profile", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" + } + }, + "org": { + "members": { + "role": { + "label": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "not": { + "signed": { + "in": "They haven't signed in to Orca yet." + } + }, + "remove": { + "confirm": "Remove", + "cancel": "Cancel", + "label": "Remove teammate" + }, + "title": "Organization members", + "load": { + "error": "Couldn't load organization members." + }, + "retry": "Try again", + "pending": { + "title": "Pending invites", + "revoke": "Revoke" + }, + "invite": { + "title": "Invite", + "placeholder": "teammate@example.com", + "role": "Invite role", + "submit": "Send" + }, + "error": { + "forbidden": "Your role can't do that.", + "conflict": "Already a member or invited.", + "remove": { + "self": "You can't remove yourself." + }, + "own": { + "role": "You can't change your own role." + }, + "not": { + "found": "That teammate is no longer in this organization." + }, + "reconnect": "Reconnect your profile to manage members.", + "unconfigured": "Organization management is unavailable in this build.", + "failed": "Something went wrong. Please try again." + }, + "subtitle": { + "named": "People in {{orgName}} who can collaborate on Orca.", + "default": "People in your organization who can collaborate on Orca." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cb1f6f58463..2543c3ce6d3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "Proyecto ya agregado", "6d3318e813": "No se pudieron importar repositorios", "3be0f7df04": "No se puede abrir la carpeta en el host seleccionado", - "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable." + "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.", + "2dcd706774": "Project also exists in another profile", + "presenceProfileOverflow": "{{names}} +{{count}} more" }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "La conexión de Linear fue reemplazada por una solicitud más reciente." + }, + "orca": { + "profiles": { + "612f7f6861": "Failed to create profile", + "319d7cf39b": "Cloud profile created", + "d6e764e7db": "Reconnect this profile", + "f0c9e11a6d": "Failed to create cloud profile", + "8b8fa73174": "Orca Cloud sign-in is not configured", + "33290e88ed": "Failed to connect profile", + "9fcb07a796": "Profile connected", + "2f6c78a039": "Failed to refresh profile auth", + "a37b5e6d37": "Signed out of profile", + "83600521e7": "Failed to sign out", + "76deec8f58": "Failed to switch organization", + "7d4bc516ee": "Failed to switch profile", + "f518e89aa5": "Project already exists in that profile", + "f03ae7f27b": "Failed to transfer project" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", "notificationPlayground": "Panel de prueba de notificaciones", "notificationPlaygroundDescription": "Disparadores solo para desarrollo para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", - "devOnly": "Solo para desarrollo" + "devOnly": "Solo para desarrollo", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "Copiar" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "Cloud sign-in unavailable" + }, + "account": "Account", + "reconnect": "Reconnect profile", + "connect": "Connect profile", + "organization": "Organization", + "create": { + "profile": { + "for": { + "org": "Create profile for org" + } + } + }, + "signout": "Sign out", + "16e3681072": "New local profile", + "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", + "f322e1f4d6": "Profile name", + "cfa59f8ad1": "Create and Switch", + "b4f9d1125d": "Local", + "4815f7d163": "Switch profile", + "d00d853e2a": "Manage profiles", + "c106c674fe": "New local profile", + "org": { + "members": "Organization members" + } + }, + "management": { + "04e7bd2a23": "Transfer", + "128c7dfe64": "Copy to", + "df8b7d876b": "Move to", + "9aa26347b3": "Project moved", + "816ce624b6": "Project copied", + "2c45bda8d3": "Manage profiles", + "2db945e4a0": "Copy or move projects from the active profile to another local profile.", + "8668cb2946": "No projects in this profile.", + "93034915ab": "Create another profile before copying projects.", + "projectCountSingular": "{{count}} project", + "projectCountPlural": "{{count}} projects" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "Move project?", + "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", + "action": "Move project" + }, + "copy": { + "title": "Copy project?", + "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", + "action": "Copy project" + }, + "live": { + "work": "Live work in this project" + }, + "cancel": "Cancel", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" + } + } + }, + "signout": { + "confirm": { + "title": "Sign out?", + "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", + "cancel": "Cancel", + "action": "Sign out" + } + }, + "switch": { + "confirm": { + "target": "the selected profile", + "title": "Switch profiles?", + "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "live": { + "work": "Live work in this profile" + }, + "cancel": "Cancel", + "switch": "Switch profile", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" + } + }, + "org": { + "members": { + "role": { + "label": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "not": { + "signed": { + "in": "They haven't signed in to Orca yet." + } + }, + "remove": { + "confirm": "Remove", + "cancel": "Cancel", + "label": "Remove teammate" + }, + "title": "Organization members", + "load": { + "error": "Couldn't load organization members." + }, + "retry": "Try again", + "pending": { + "title": "Pending invites", + "revoke": "Revoke" + }, + "invite": { + "title": "Invite", + "placeholder": "teammate@example.com", + "role": "Invite role", + "submit": "Send" + }, + "error": { + "forbidden": "Your role can't do that.", + "conflict": "Already a member or invited.", + "remove": { + "self": "You can't remove yourself." + }, + "own": { + "role": "You can't change your own role." + }, + "not": { + "found": "That teammate is no longer in this organization." + }, + "reconnect": "Reconnect your profile to manage members.", + "unconfigured": "Organization management is unavailable in this build.", + "failed": "Something went wrong. Please try again." + }, + "subtitle": { + "named": "People in {{orgName}} who can collaborate on Orca.", + "default": "People in your organization who can collaborate on Orca." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b17bf2dcc03..9727ae4adc4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "プロジェクトはすでに追加されています", "6d3318e813": "repos のインポートに失敗しました", "3be0f7df04": "選択したランタイムでフォルダーを開けません", - "15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。" + "15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。", + "2dcd706774": "プロジェクトは別のプロファイルにも存在します", + "presenceProfileOverflow": "{{names}} 他 {{count}} 件" }, "settings": { "e12dab333b": "サーバーの切り替えに失敗しました", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "Linear 接続は新しいリクエストに置き換えられました。" + }, + "orca": { + "profiles": { + "612f7f6861": "プロファイルの作成に失敗しました", + "319d7cf39b": "クラウド プロファイルが作成されました", + "d6e764e7db": "このプロファイルを再接続してください", + "f0c9e11a6d": "クラウド プロファイルの作成に失敗しました", + "8b8fa73174": "Orca Cloud のサインインが構成されていません", + "33290e88ed": "プロファイルの接続に失敗しました", + "9fcb07a796": "プロファイルが接続されました", + "2f6c78a039": "プロファイル認証の更新に失敗しました", + "a37b5e6d37": "プロファイルからサインアウトしました", + "83600521e7": "サインアウトに失敗しました", + "76deec8f58": "組織の切り替えに失敗しました", + "7d4bc516ee": "プロファイルの切り替えに失敗しました", + "f518e89aa5": "プロジェクトはそのプロファイルに既に存在します", + "f03ae7f27b": "プロジェクトの転送に失敗しました" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "インラインの設定リンクとフッターアクション付きの永続プロンプト。", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "ワークスペースでの使用" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "コピー" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "クラウド サインインは利用できません" + }, + "reconnect": "プロファイルを再接続", + "connect": "プロファイルを接続", + "organization": "組織", + "create": { + "profile": { + "for": { + "org": "組織用のプロファイルを作成" + } + } + }, + "signout": "サインアウト", + "16e3681072": "新しいローカル プロファイル", + "e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。", + "f322e1f4d6": "プロファイル名", + "cfa59f8ad1": "作成して切り替え", + "b4f9d1125d": "ローカル", + "4815f7d163": "プロファイルを切り替え", + "d00d853e2a": "プロファイルを管理", + "c106c674fe": "新しいローカル プロファイル", + "account": "アカウント", + "org": { + "members": "組織のメンバー" + } + }, + "management": { + "04e7bd2a23": "転送", + "128c7dfe64": "コピー先", + "df8b7d876b": "移動先", + "9aa26347b3": "プロジェクトを移動しました", + "816ce624b6": "プロジェクトをコピーしました", + "2c45bda8d3": "プロファイルを管理", + "2db945e4a0": "アクティブなプロファイルから別のローカル プロファイルへプロジェクトをコピーまたは移動します。", + "8668cb2946": "このプロファイルにはプロジェクトがありません。", + "93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。", + "projectCountSingular": "{{count}} 件のプロジェクト", + "projectCountPlural": "{{count}} 件のプロジェクト" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "プロジェクトを移動しますか?", + "description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。", + "action": "プロジェクトを移動" + }, + "copy": { + "title": "プロジェクトをコピーしますか?", + "description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。", + "action": "プロジェクトをコピー" + }, + "live": { + "work": "このプロジェクトで実行中の作業" + }, + "cancel": "キャンセル", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" + } + } + }, + "signout": { + "confirm": { + "title": "サインアウトしますか?", + "description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。", + "cancel": "キャンセル", + "action": "サインアウト" + } + }, + "switch": { + "confirm": { + "target": "選択したプロファイル", + "title": "プロファイルを切り替えますか?", + "description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。", + "live": { + "work": "このプロファイルで実行中の作業" + }, + "cancel": "キャンセル", + "switch": "プロファイルを切り替え", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" + } + }, + "org": { + "members": { + "role": { + "label": "ロール", + "owner": "オーナー", + "admin": "管理者", + "member": "メンバー" + }, + "not": { + "signed": { + "in": "まだ Orca にサインインしていません。" + } + }, + "remove": { + "confirm": "削除", + "cancel": "キャンセル", + "label": "メンバーを削除" + }, + "title": "組織のメンバー", + "load": { + "error": "組織のメンバーを読み込めませんでした。" + }, + "retry": "再試行", + "pending": { + "title": "保留中の招待", + "revoke": "取り消す" + }, + "invite": { + "title": "招待", + "placeholder": "teammate@example.com", + "role": "招待するロール", + "submit": "送信" + }, + "error": { + "forbidden": "あなたのロールではこの操作を実行できません。", + "conflict": "すでにメンバーまたは招待済みです。", + "remove": { + "self": "自分自身を削除することはできません。" + }, + "own": { + "role": "自分のロールは変更できません。" + }, + "not": { + "found": "そのメンバーはこの組織に存在しません。" + }, + "reconnect": "メンバーを管理するにはプロファイルを再接続してください。", + "unconfigured": "このビルドでは組織の管理を利用できません。", + "failed": "問題が発生しました。もう一度お試しください。" + }, + "subtitle": { + "named": "{{orgName}} で Orca を共同利用できるメンバーです。", + "default": "組織内で Orca を共同利用できるメンバーです。" + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d12f3cdd776..bff86bf9af6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "프로젝트가 이미 추가되었습니다.", "6d3318e813": "repos를 가져오지 못했습니다.", "3be0f7df04": "선택한 런타임에서 폴더를 열 수 없습니다.", - "15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다." + "15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다.", + "2dcd706774": "프로젝트가 다른 프로필에도 있습니다", + "presenceProfileOverflow": "{{names}} 외 {{count}}개" }, "settings": { "e12dab333b": "서버를 전환하지 못했습니다.", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "더 새로운 요청이 있어 Linear 연결 요청을 건너뛰었습니다." + }, + "orca": { + "profiles": { + "612f7f6861": "프로필을 생성하지 못했습니다", + "319d7cf39b": "클라우드 프로필이 생성되었습니다", + "d6e764e7db": "이 프로필을 다시 연결하세요", + "f0c9e11a6d": "클라우드 프로필을 생성하지 못했습니다", + "8b8fa73174": "Orca Cloud 로그인이 구성되어 있지 않습니다", + "33290e88ed": "프로필을 연결하지 못했습니다", + "9fcb07a796": "프로필이 연결되었습니다", + "2f6c78a039": "프로필 인증을 새로 고치지 못했습니다", + "a37b5e6d37": "프로필에서 로그아웃했습니다", + "83600521e7": "로그아웃하지 못했습니다", + "76deec8f58": "조직을 전환하지 못했습니다", + "7d4bc516ee": "프로필을 전환하지 못했습니다", + "f518e89aa5": "프로젝트가 해당 프로필에 이미 있습니다", + "f03ae7f27b": "프로젝트를 전송하지 못했습니다" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "인라인 설정 링크와 바닥글 작업이 있는 영구 프롬프트입니다.", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "워크스페이스에서 사용" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "복사" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "클라우드 로그인을 사용할 수 없습니다" + }, + "reconnect": "프로필 다시 연결", + "connect": "프로필 연결", + "organization": "조직", + "create": { + "profile": { + "for": { + "org": "조직용 프로필 만들기" + } + } + }, + "signout": "로그아웃", + "16e3681072": "새 로컬 프로필", + "e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.", + "f322e1f4d6": "프로필 이름", + "cfa59f8ad1": "만들고 전환", + "b4f9d1125d": "로컬", + "4815f7d163": "프로필 전환", + "d00d853e2a": "프로필 관리", + "c106c674fe": "새 로컬 프로필", + "account": "계정", + "org": { + "members": "조직 구성원" + } + }, + "management": { + "04e7bd2a23": "전송", + "128c7dfe64": "복사 대상", + "df8b7d876b": "이동 대상", + "9aa26347b3": "프로젝트를 이동했습니다", + "816ce624b6": "프로젝트를 복사했습니다", + "2c45bda8d3": "프로필 관리", + "2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.", + "8668cb2946": "이 프로필에는 프로젝트가 없습니다.", + "93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.", + "projectCountSingular": "{{count}}개의 프로젝트", + "projectCountPlural": "{{count}}개의 프로젝트" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "프로젝트를 이동하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.", + "action": "프로젝트 이동" + }, + "copy": { + "title": "프로젝트를 복사하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.", + "action": "프로젝트 복사" + }, + "live": { + "work": "이 프로젝트에서 진행 중인 작업" + }, + "cancel": "취소", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" + } + } + }, + "signout": { + "confirm": { + "title": "로그아웃하시겠습니까?", + "description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.", + "cancel": "취소", + "action": "로그아웃" + } + }, + "switch": { + "confirm": { + "target": "선택한 프로필", + "title": "프로필을 전환하시겠습니까?", + "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.", + "live": { + "work": "이 프로필에서 진행 중인 작업" + }, + "cancel": "취소", + "switch": "프로필 전환", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" + } + }, + "org": { + "members": { + "role": { + "label": "역할", + "owner": "소유자", + "admin": "관리자", + "member": "구성원" + }, + "not": { + "signed": { + "in": "아직 Orca에 로그인하지 않았습니다." + } + }, + "remove": { + "confirm": "삭제", + "cancel": "취소", + "label": "구성원 삭제" + }, + "title": "조직 구성원", + "load": { + "error": "조직 구성원을 불러오지 못했습니다." + }, + "retry": "다시 시도", + "pending": { + "title": "대기 중인 초대", + "revoke": "철회" + }, + "invite": { + "title": "초대", + "placeholder": "teammate@example.com", + "role": "초대 역할", + "submit": "보내기" + }, + "error": { + "forbidden": "현재 역할로는 이 작업을 수행할 수 없습니다.", + "conflict": "이미 구성원이거나 초대되었습니다.", + "remove": { + "self": "자신은 삭제할 수 없습니다." + }, + "own": { + "role": "자신의 역할은 변경할 수 없습니다." + }, + "not": { + "found": "해당 구성원이 더 이상 이 조직에 없습니다." + }, + "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", + "unconfigured": "이 빌드에서는 조직 관리를 사용할 수 없습니다.", + "failed": "문제가 발생했습니다. 다시 시도하세요." + }, + "subtitle": { + "named": "{{orgName}}에서 Orca로 협업할 수 있는 사용자입니다.", + "default": "조직에서 Orca로 협업할 수 있는 사용자입니다." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b86a41a0354..a0b21163a78 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "项目已添加过了", "6d3318e813": "导入存储库失败", "3be0f7df04": "无法在所选运行时打开文件夹", - "15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。" + "15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。", + "2dcd706774": "该项目也存在于另一个配置文件中", + "presenceProfileOverflow": "{{names}} 及另外 {{count}} 个" }, "settings": { "e12dab333b": "切换服务器失败", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "Linear 连接已被新的请求取代。" + }, + "orca": { + "profiles": { + "612f7f6861": "创建配置文件失败", + "319d7cf39b": "已创建云配置文件", + "d6e764e7db": "请重新连接此配置文件", + "f0c9e11a6d": "创建云配置文件失败", + "8b8fa73174": "未配置 Orca Cloud 登录", + "33290e88ed": "连接配置文件失败", + "9fcb07a796": "配置文件已连接", + "2f6c78a039": "刷新配置文件身份验证失败", + "a37b5e6d37": "已退出配置文件", + "83600521e7": "退出登录失败", + "76deec8f58": "切换组织失败", + "7d4bc516ee": "切换配置文件失败", + "f518e89aa5": "该项目已存在于那个配置文件中", + "f03ae7f27b": "转移项目失败" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "带有内联设置链接和底部操作按钮的持久提示。", "notificationPlayground": "通知测试面板", "notificationPlaygroundDescription": "用于检查提示布局、恢复操作和长文案换行的开发触发器。", - "devOnly": "仅开发" + "devOnly": "仅开发", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "在工作区中使用" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "复制" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "云登录不可用" + }, + "reconnect": "重新连接配置文件", + "connect": "连接配置文件", + "organization": "组织", + "create": { + "profile": { + "for": { + "org": "为组织创建配置文件" + } + } + }, + "signout": "退出登录", + "16e3681072": "新建本地配置文件", + "e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。", + "f322e1f4d6": "配置文件名称", + "cfa59f8ad1": "创建并切换", + "b4f9d1125d": "本地", + "4815f7d163": "切换配置文件", + "d00d853e2a": "管理配置文件", + "c106c674fe": "新建本地配置文件", + "account": "帐户", + "org": { + "members": "组织成员" + } + }, + "management": { + "04e7bd2a23": "转移", + "128c7dfe64": "复制到", + "df8b7d876b": "移动到", + "9aa26347b3": "项目已移动", + "816ce624b6": "项目已复制", + "2c45bda8d3": "管理配置文件", + "2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。", + "8668cb2946": "此配置文件中没有项目。", + "93034915ab": "复制项目前,请先创建另一个配置文件。", + "projectCountSingular": "{{count}} 个项目", + "projectCountPlural": "{{count}} 个项目" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "移动项目?", + "description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。", + "action": "移动项目" + }, + "copy": { + "title": "复制项目?", + "description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。", + "action": "复制项目" + }, + "live": { + "work": "此项目中正在进行的工作" + }, + "cancel": "取消", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" + } + } + }, + "signout": { + "confirm": { + "title": "退出登录?", + "description": "退出 {{profileName}},并在此设备上保留其项目、工作树和本地元数据。", + "cancel": "取消", + "action": "退出登录" + } + }, + "switch": { + "confirm": { + "target": "所选配置文件", + "title": "切换配置文件?", + "description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。", + "live": { + "work": "此配置文件中正在进行的工作" + }, + "cancel": "取消", + "switch": "切换配置文件", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" + } + }, + "org": { + "members": { + "role": { + "label": "角色", + "owner": "所有者", + "admin": "管理员", + "member": "成员" + }, + "not": { + "signed": { + "in": "他们尚未登录 Orca。" + } + }, + "remove": { + "confirm": "移除", + "cancel": "取消", + "label": "移除成员" + }, + "title": "组织成员", + "load": { + "error": "无法加载组织成员。" + }, + "retry": "重试", + "pending": { + "title": "待处理的邀请", + "revoke": "撤销" + }, + "invite": { + "title": "邀请", + "placeholder": "teammate@example.com", + "role": "邀请角色", + "submit": "发送" + }, + "error": { + "forbidden": "你的角色无法执行此操作。", + "conflict": "已是成员或已被邀请。", + "remove": { + "self": "无法移除你自己。" + }, + "own": { + "role": "无法更改你自己的角色。" + }, + "not": { + "found": "该成员已不在此组织中。" + }, + "reconnect": "请重新连接你的配置文件以管理成员。", + "unconfigured": "此版本不支持组织管理。", + "failed": "出现问题。请重试。" + }, + "subtitle": { + "named": "{{orgName}} 中可以协作使用 Orca 的成员。", + "default": "你的组织中可以协作使用 Orca 的成员。" + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b27c9791718..4fb8bdc8581 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -35,6 +35,7 @@ import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './slices/orca-profiles' import { createNewIssueDraftSlice } from './slices/new-issue-draft' import { e2eConfig } from '@/lib/e2e-config' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' @@ -75,6 +76,7 @@ export const useAppStore = create()((...a) => ({ ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index bb41d9e7fba..15b6f3444ab 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -140,6 +140,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' function createTestStore() { @@ -179,6 +180,7 @@ function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) } diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts new file mode 100644 index 00000000000..7f4f16abf5c --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileResult, + OrcaProfileAuthStatus, + OrcaProfileListState, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../../../shared/orca-profiles' + +const listState: OrcaProfileListState = { + activeProfileId: 'local-default', + profiles: [ + { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] +} + +const localAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +const connectedCloud = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + linkedAt: 3 +} + +const connectedOrganizations = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: connectedCloud, + organizations: connectedOrganizations, + capabilities: { + flags: { share: true }, + refreshedAt: 4 + } +} + +const orcaProfilesApi = { + list: vi.fn(), + authStatus: vi.fn(), + createLocal: vi.fn(), + createCloudLinked: vi.fn(), + connectCurrent: vi.fn(), + refreshAuth: vi.fn(), + signOutCurrent: vi.fn(), + selectOrg: vi.fn(), + switchProfile: vi.fn(), + transferProject: vi.fn() +} + +describe('orca profile auth actions slice', () => { + beforeEach(() => { + vi.resetAllMocks() + orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus) + vi.stubGlobal('window', { + api: { + orcaProfiles: orcaProfilesApi + } + }) + }) + + it('connects the current profile and stores returned cloud metadata', async () => { + const connectedProfiles = [ + { + ...listState.profiles[0], + kind: 'cloud-linked' as const, + cloud: connectedAuthStatus.cloud + } + ] + const result: ConnectCurrentOrcaProfileResult = { + status: 'connected', + auth: connectedAuthStatus, + activeProfileId: 'local-default', + profiles: connectedProfiles + } + orcaProfilesApi.connectCurrent.mockResolvedValue(result) + const store = createTestStore() + + const pending = store.getState().connectCurrentOrcaProfile() + + expect(store.getState().orcaProfileConnecting).toBe(true) + await expect(pending).resolves.toEqual(result) + expect(store.getState().orcaProfileConnecting).toBe(false) + expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus) + expect(store.getState().orcaProfiles).toEqual(connectedProfiles) + }) + + it('refreshes current profile auth and stores fresh capability flags', async () => { + const refreshedAuthStatus: OrcaProfileAuthStatus = { + ...connectedAuthStatus, + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 8 + } + } + const result: RefreshCurrentOrcaProfileAuthResult = { + status: 'refreshed', + auth: refreshedAuthStatus, + activeProfileId: 'local-default', + profiles: [ + { + ...listState.profiles[0], + kind: 'cloud-linked', + cloud: refreshedAuthStatus.cloud + } + ] + } + orcaProfilesApi.refreshAuth.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().refreshCurrentOrcaProfileAuth()).resolves.toEqual(result) + expect(orcaProfilesApi.refreshAuth).toHaveBeenCalledOnce() + expect(store.getState().orcaProfileAuthStatus).toEqual(refreshedAuthStatus) + expect(store.getState().orcaProfiles).toEqual(result.profiles) + }) + + it('creates a cloud-linked profile and stores the returned profile list', async () => { + const cloudProfile = { + id: 'cloud-acme', + name: 'Acme', + avatar: { kind: 'initials' as const, initials: 'A', color: 'neutral' as const }, + kind: 'cloud-linked' as const, + createdAt: 5, + updatedAt: 5, + lastOpenedAt: 5, + cloud: { + ...connectedCloud, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + } + const result: CreateCloudLinkedOrcaProfileResult = { + status: 'created', + auth: connectedAuthStatus, + activeProfileId: 'local-default', + profiles: [...listState.profiles, cloudProfile], + profile: cloudProfile + } + orcaProfilesApi.createCloudLinked.mockResolvedValue(result) + const store = createTestStore() + + await expect( + store.getState().createCloudLinkedOrcaProfile({ orgId: 'org-1', name: 'Acme' }) + ).resolves.toEqual(result) + expect(orcaProfilesApi.createCloudLinked).toHaveBeenCalledWith({ + orgId: 'org-1', + name: 'Acme' + }) + expect(store.getState().orcaProfiles).toEqual(result.profiles) + }) + + it('signs out the current profile without dropping local profile data', async () => { + const result: SignOutCurrentOrcaProfileResult = { + status: 'signed-out', + auth: localAuthStatus, + activeProfileId: 'local-default', + profiles: listState.profiles + } + orcaProfilesApi.signOutCurrent.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().signOutCurrentOrcaProfile()).resolves.toEqual(result) + expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus) + expect(store.getState().orcaProfiles).toEqual(listState.profiles) + }) + + it('selects a cloud organization and refreshes auth state', async () => { + const selectedAuthStatus: OrcaProfileAuthStatus = { + ...connectedAuthStatus, + cloud: { + ...connectedCloud, + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + } + const result: SelectOrcaProfileOrgResult = { + status: 'selected', + auth: selectedAuthStatus, + activeProfileId: 'local-default', + profiles: [ + { + ...listState.profiles[0], + kind: 'cloud-linked', + cloud: selectedAuthStatus.cloud + } + ] + } + orcaProfilesApi.selectOrg.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().selectOrcaProfileOrg('org-1')).resolves.toEqual(result) + expect(orcaProfilesApi.selectOrg).toHaveBeenCalledWith({ orgId: 'org-1' }) + expect(store.getState().orcaProfileAuthStatus).toEqual(selectedAuthStatus) + expect(store.getState().orcaProfileAuthStatus?.organizations).toEqual(connectedOrganizations) + }) +}) diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts new file mode 100644 index 00000000000..c8212962821 --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts @@ -0,0 +1,211 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileResult, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../../../shared/orca-profiles' +import type { AppState } from '../types' + +export type OrcaProfilesAuthActions = { + createCloudLinkedOrcaProfile: (args: { + orgId?: string + name?: string + }) => Promise + connectCurrentOrcaProfile: () => Promise + refreshCurrentOrcaProfileAuth: () => Promise + signOutCurrentOrcaProfile: () => Promise + selectOrcaProfileOrg: (orgId: string) => Promise +} + +// Why a separate module: the cloud-auth actions share the profiles slice's +// state keys but form their own cohesive surface (connect/refresh/sign-out/ +// org selection), and the combined slice file exceeded the repo line budget. +export const createOrcaProfilesAuthActions: StateCreator< + AppState, + [], + [], + OrcaProfilesAuthActions +> = (set, get) => ({ + createCloudLinkedOrcaProfile: async (args) => { + try { + const result = await window.api.orcaProfiles.createCloudLinked(args) + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'created' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'created') { + toast.success( + translate('auto.store.slices.orca.profiles.319d7cf39b', 'Cloud profile created') + ) + } else if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to create Orca cloud profile:', err) + toast.error( + translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + connectCurrentOrcaProfile: async () => { + if (get().orcaProfileConnecting) { + return null + } + set({ orcaProfileConnecting: true }) + try { + const result = await window.api.orcaProfiles.connectCurrent() + set({ + orcaProfileConnecting: false, + orcaProfileAuthStatus: result.auth, + ...(result.status === 'connected' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'unconfigured') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.8b8fa73174', + 'Orca Cloud sign-in is not configured' + ), + { + description: result.auth.setupMessage + } + ) + } else if (result.status === 'failed') { + toast.error( + translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), + { description: result.error } + ) + } else if (result.status === 'connected') { + toast.success(translate('auto.store.slices.orca.profiles.9fcb07a796', 'Profile connected')) + } + return result + } catch (err) { + console.error('Failed to connect Orca profile:', err) + set({ orcaProfileConnecting: false }) + toast.error( + translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + refreshCurrentOrcaProfileAuth: async () => { + try { + const result = await window.api.orcaProfiles.refreshAuth() + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'refreshed' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to refresh Orca profile auth:', err) + toast.error( + translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + signOutCurrentOrcaProfile: async () => { + try { + const result = await window.api.orcaProfiles.signOutCurrent() + set({ + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles, + orcaProfileAuthStatus: result.auth + }) + toast.success( + translate('auto.store.slices.orca.profiles.a37b5e6d37', 'Signed out of profile') + ) + return result + } catch (err) { + console.error('Failed to sign out of Orca profile:', err) + toast.error(translate('auto.store.slices.orca.profiles.83600521e7', 'Failed to sign out'), { + description: err instanceof Error ? err.message : String(err) + }) + return null + } + }, + + selectOrcaProfileOrg: async (orgId) => { + try { + const result = await window.api.orcaProfiles.selectOrg({ orgId }) + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'selected' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to switch Orca profile org:', err) + toast.error( + translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + } +}) diff --git a/src/renderer/src/store/slices/orca-profiles.test.ts b/src/renderer/src/store/slices/orca-profiles.test.ts new file mode 100644 index 00000000000..142037c02fe --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + CreateLocalOrcaProfileResult, + OrcaProfileAuthStatus, + OrcaProfileListResult, + TransferOrcaProfileProjectResult +} from '../../../../shared/orca-profiles' + +const { toastErrorMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: toastErrorMock, + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) + +const listState: OrcaProfileListResult = { + activeProfileId: 'local-default', + multiProfileUi: false, + profiles: [ + { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] +} + +const createdState: CreateLocalOrcaProfileResult = { + activeProfileId: 'local-default', + profiles: [ + ...listState.profiles, + { + id: 'local-work', + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 2, + updatedAt: 2, + lastOpenedAt: 2 + } + ], + profile: { + id: 'local-work', + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 2, + updatedAt: 2, + lastOpenedAt: 2 + } +} + +const localAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + linkedAt: 3 + }, + capabilities: { + flags: { share: true }, + refreshedAt: 4 + } +} + +const orcaProfilesApi = { + list: vi.fn(), + authStatus: vi.fn(), + createLocal: vi.fn(), + createCloudLinked: vi.fn(), + connectCurrent: vi.fn(), + refreshAuth: vi.fn(), + signOutCurrent: vi.fn(), + selectOrg: vi.fn(), + switchProfile: vi.fn(), + transferProject: vi.fn() +} + +describe('orca profile slice', () => { + beforeEach(() => { + vi.resetAllMocks() + toastErrorMock.mockReset() + orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus) + vi.stubGlobal('window', { + api: { + orcaProfiles: orcaProfilesApi + } + }) + }) + + it('fetches profiles into store state', async () => { + orcaProfilesApi.list.mockResolvedValue(listState) + const store = createTestStore() + + await store.getState().fetchOrcaProfiles() + + expect(store.getState().activeOrcaProfileId).toBe('local-default') + expect(store.getState().orcaProfiles).toEqual(listState.profiles) + expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus) + expect(store.getState().orcaProfilesMultiProfileUi).toBe(false) + expect(store.getState().orcaProfilesLoading).toBe(false) + }) + + it('stores the multi-profile UI flag from the list result', async () => { + orcaProfilesApi.list.mockResolvedValue({ ...listState, multiProfileUi: true }) + const store = createTestStore() + + await store.getState().fetchOrcaProfiles() + + expect(store.getState().orcaProfilesMultiProfileUi).toBe(true) + }) + + it('creates a local profile and returns the created summary', async () => { + orcaProfilesApi.createLocal.mockResolvedValue(createdState) + const store = createTestStore() + + const profile = await store.getState().createLocalOrcaProfile('Work') + + expect(profile).toEqual(createdState.profile) + expect(orcaProfilesApi.createLocal).toHaveBeenCalledWith({ name: 'Work' }) + expect(store.getState().orcaProfiles).toEqual(createdState.profiles) + }) + + it('fetches auth status independently', async () => { + orcaProfilesApi.authStatus.mockResolvedValue(connectedAuthStatus) + const store = createTestStore() + + await expect(store.getState().fetchOrcaProfileAuthStatus()).resolves.toEqual( + connectedAuthStatus + ) + expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus) + }) + + it('sets switching state while requesting a profile switch', async () => { + orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'relaunching' }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + const result = await store.getState().switchOrcaProfile('local-work') + + expect(result).toEqual({ status: 'relaunching' }) + expect(orcaProfilesApi.switchProfile).toHaveBeenCalledWith({ profileId: 'local-work' }) + expect(store.getState().orcaProfileSwitching).toBe(true) + }) + + it('releases switching state when main reports the profile is already active', async () => { + // Why: a stale renderer activeOrcaProfileId must not lock the switcher + // forever when no relaunch is actually coming. + orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'already-active' }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + const result = await store.getState().switchOrcaProfile('local-work') + + expect(result).toEqual({ status: 'already-active' }) + expect(store.getState().orcaProfileSwitching).toBe(false) + }) + + it('does not call main when switching to the active profile', async () => { + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + const result = await store.getState().switchOrcaProfile('local-default') + + expect(result).toEqual({ status: 'already-active' }) + expect(orcaProfilesApi.switchProfile).not.toHaveBeenCalled() + }) + + it('transfers projects through the profile API', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'transferred', + mode: 'copy', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-2', + targetProjectId: 'repo:repo-2' + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + const result = await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + + expect(result).toEqual(transferResult) + expect(orcaProfilesApi.transferProject).toHaveBeenCalledWith({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + }) + + it('marks profile switching when a project transfer relaunches the app', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'transferred', + mode: 'move', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-1', + targetProjectId: 'repo:repo-1', + willRelaunch: true + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'move' + }) + + expect(store.getState().orcaProfileSwitching).toBe(true) + }) + + it('warns when a project already exists in the target profile', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'duplicate-target', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + duplicateRepoId: 'repo-existing' + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + + expect(toastErrorMock).toHaveBeenCalledWith('Project already exists in that profile') + expect(store.getState().orcaProfileSwitching).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/orca-profiles.ts b/src/renderer/src/store/slices/orca-profiles.ts new file mode 100644 index 00000000000..d885e96b7e3 --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles.ts @@ -0,0 +1,153 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + OrcaProfileAuthStatus, + OrcaProfileSummary, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../../../shared/orca-profiles' +import type { AppState } from '../types' +import { + createOrcaProfilesAuthActions, + type OrcaProfilesAuthActions +} from './orca-profiles-auth-actions' + +export type OrcaProfilesSlice = OrcaProfilesAuthActions & { + orcaProfiles: OrcaProfileSummary[] + activeOrcaProfileId: string | null + orcaProfileAuthStatus: OrcaProfileAuthStatus | null + orcaProfilesMultiProfileUi: boolean + orcaProfilesLoading: boolean + orcaProfileSwitching: boolean + orcaProfileConnecting: boolean + fetchOrcaProfiles: () => Promise + fetchOrcaProfileAuthStatus: () => Promise + createLocalOrcaProfile: (name?: string) => Promise + switchOrcaProfile: (profileId: string) => Promise + transferOrcaProfileProject: ( + args: TransferOrcaProfileProjectArgs + ) => Promise +} + +export const createOrcaProfilesSlice: StateCreator = ( + set, + get, + api +) => ({ + orcaProfiles: [], + activeOrcaProfileId: null, + orcaProfileAuthStatus: null, + orcaProfilesMultiProfileUi: false, + orcaProfilesLoading: false, + orcaProfileSwitching: false, + orcaProfileConnecting: false, + + fetchOrcaProfiles: async () => { + set({ orcaProfilesLoading: true }) + try { + const [state, authStatus] = await Promise.all([ + window.api.orcaProfiles.list(), + window.api.orcaProfiles.authStatus() + ]) + set({ + activeOrcaProfileId: state.activeProfileId, + orcaProfiles: state.profiles, + orcaProfilesMultiProfileUi: state.multiProfileUi, + orcaProfileAuthStatus: authStatus, + orcaProfilesLoading: false + }) + } catch (err) { + console.error('Failed to fetch Orca profiles:', err) + set({ orcaProfilesLoading: false }) + } + }, + + fetchOrcaProfileAuthStatus: async () => { + try { + const authStatus = await window.api.orcaProfiles.authStatus() + set({ orcaProfileAuthStatus: authStatus }) + return authStatus + } catch (err) { + console.error('Failed to fetch Orca profile auth status:', err) + return null + } + }, + + createLocalOrcaProfile: async (name) => { + try { + const state = await window.api.orcaProfiles.createLocal({ name }) + set({ + activeOrcaProfileId: state.activeProfileId, + orcaProfiles: state.profiles + }) + void get().fetchOrcaProfileAuthStatus() + return state.profile + } catch (err) { + console.error('Failed to create Orca profile:', err) + toast.error( + translate('auto.store.slices.orca.profiles.612f7f6861', 'Failed to create profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + ...createOrcaProfilesAuthActions(set, get, api), + + switchOrcaProfile: async (profileId) => { + if (!profileId || profileId === get().activeOrcaProfileId) { + return { status: 'already-active' } + } + set({ orcaProfileSwitching: true }) + try { + const result = await window.api.orcaProfiles.switchProfile({ profileId }) + if (result?.status !== 'relaunching') { + // Why: only a relaunch may keep the switcher locked; a stale + // "already-active" answer would otherwise disable it forever. + set({ orcaProfileSwitching: false }) + } + return result + } catch (err) { + console.error('Failed to switch Orca profile:', err) + set({ orcaProfileSwitching: false }) + toast.error( + translate('auto.store.slices.orca.profiles.7d4bc516ee', 'Failed to switch profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + transferOrcaProfileProject: async (args) => { + try { + const result = await window.api.orcaProfiles.transferProject(args) + if (result.status === 'duplicate-target') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.f518e89aa5', + 'Project already exists in that profile' + ) + ) + } + if (result.status === 'transferred' && result.willRelaunch) { + set({ orcaProfileSwitching: true }) + } + return result + } catch (err) { + console.error('Failed to transfer Orca profile project:', err) + toast.error( + translate('auto.store.slices.orca.profiles.f03ae7f27b', 'Failed to transfer project'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + } +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts new file mode 100644 index 00000000000..738a805bba5 --- /dev/null +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -0,0 +1,117 @@ +import { beforeEach, vi, type Mock } from 'vitest' +import { toast } from 'sonner' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// Shared harness for the repo-slice runtime-routing suite: sample repos, IPC/runtime +// mocks, and the window stub reset between tests. Extracted to keep the test file itself +// under the max-lines limit. + +export const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +export const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +export const sshRepo: Repo = { + id: 'ssh-repo', + path: '/home/orca/project', + displayName: 'SSH', + badgeColor: '#222', + addedAt: 3, + connectionId: 'ssh-1' +} + +export const reposList: Mock = vi.fn() +export const reposAdd: Mock = vi.fn() +export const reposPickFolder: Mock = vi.fn() +export const reposClone: Mock = vi.fn() +export const reposCloneRemote: Mock = vi.fn() +export const reposRemove: Mock = vi.fn() +export const reposUpdate: Mock = vi.fn() +export const reposReorder: Mock = vi.fn() +export const projectsCreateHostSetup: Mock = vi.fn() +export const projectsSetupExistingFolder: Mock = vi.fn() +export const projectsUpdateHostSetup: Mock = vi.fn() +export const projectsDeleteHostSetup: Mock = vi.fn() +export const projectsUpdate: Mock = vi.fn() +export const projectGroupsMoveProject: Mock = vi.fn() +export const ptyKill: Mock = vi.fn() +export const runtimeEnvironmentCall: Mock = vi.fn() +export const runtimeEnvironmentTransportCall: Mock = vi.fn() +export const orcaProfileFindProjectProfiles: Mock = vi.fn() + +// Registers the per-test reset + window stub. Call once inside the suite's module scope. +export function installReposRuntimeRoutingHarness(): void { + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.mocked(toast.error).mockReset() + vi.mocked(toast.info).mockReset() + vi.mocked(toast.success).mockReset() + vi.mocked(toast.warning).mockReset() + reposList.mockReset() + reposAdd.mockReset() + reposPickFolder.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + projectsUpdate.mockReset() + projectGroupsMoveProject.mockReset() + ptyKill.mockReset() + orcaProfileFindProjectProfiles.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + add: reposAdd, + clone: reposClone, + cloneRemote: reposCloneRemote, + pickFolder: reposPickFolder, + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + projects: { + update: projectsUpdate, + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + projectGroups: { + moveProject: projectGroupsMoveProject + }, + orcaProfiles: { + findProjectProfiles: orcaProfileFindProjectProfiles + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) +} diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 38087dc087e..73eb1927d0e 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,105 +1,38 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/types' +import { toast } from 'sonner' import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + installReposRuntimeRoutingHarness, + localRepo, + orcaProfileFindProjectProfiles, + projectGroupsMoveProject, + projectsSetupExistingFolder, + ptyKill, + remoteRepo, + reposAdd, + reposClone, + reposCloneRemote, + reposList, + reposPickFolder, + reposRemove, + reposReorder, + reposUpdate, + runtimeEnvironmentCall, + sshRepo +} from './repos-runtime-routing-fixture' -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/remote', - displayName: 'Remote', - badgeColor: '#111', - addedAt: 2 -} - -const sshRepo: Repo = { - id: 'ssh-repo', - path: '/home/orca/project', - displayName: 'SSH', - badgeColor: '#222', - addedAt: 3, - connectionId: 'ssh-1' -} - -const reposList = vi.fn() -const reposAdd = vi.fn() -const reposPickFolder = vi.fn() -const reposClone = vi.fn() -const reposCloneRemote = vi.fn() -const reposRemove = vi.fn() -const reposUpdate = vi.fn() -const reposReorder = vi.fn() -const projectsCreateHostSetup = vi.fn() -const projectsSetupExistingFolder = vi.fn() -const projectsUpdateHostSetup = vi.fn() -const projectsDeleteHostSetup = vi.fn() -const projectsUpdate = vi.fn() -const projectGroupsMoveProject = vi.fn() -const ptyKill = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - reposAdd.mockReset() - reposPickFolder.mockReset() - reposClone.mockReset() - reposCloneRemote.mockReset() - reposRemove.mockReset() - reposUpdate.mockReset() - reposReorder.mockReset() - projectsCreateHostSetup.mockReset() - projectsSetupExistingFolder.mockReset() - projectsUpdateHostSetup.mockReset() - projectsDeleteHostSetup.mockReset() - projectsUpdate.mockReset() - projectGroupsMoveProject.mockReset() - ptyKill.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { - list: reposList, - add: reposAdd, - clone: reposClone, - cloneRemote: reposCloneRemote, - pickFolder: reposPickFolder, - remove: reposRemove, - update: reposUpdate, - reorder: reposReorder - }, - projects: { - update: projectsUpdate, - createHostSetup: projectsCreateHostSetup, - setupExistingFolder: projectsSetupExistingFolder, - updateHostSetup: projectsUpdateHostSetup, - deleteHostSetup: projectsDeleteHostSetup - }, - projectGroups: { - moveProject: projectGroupsMoveProject - }, - pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) +installReposRuntimeRoutingHarness() describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { @@ -240,6 +173,39 @@ describe('repo slice runtime routing', () => { }) expect(reposAdd).not.toHaveBeenCalled() expect(reposPickFolder).not.toHaveBeenCalled() + expect(orcaProfileFindProjectProfiles).not.toHaveBeenCalled() + }) + + it('warns when a local project is already present in another profile', async () => { + reposAdd.mockResolvedValue({ repo: localRepo }) + orcaProfileFindProjectProfiles.mockResolvedValue({ + projects: [ + { + profileId: 'work', + profileName: 'Work', + profileKind: 'local', + repoId: 'work-repo', + repoName: 'Local' + } + ] + }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + await expect(store.getState().addRepoPath('/local')).resolves.toEqual({ + ...localRepo, + executionHostId: 'local' + }) + + expect(orcaProfileFindProjectProfiles).toHaveBeenCalledWith({ + path: '/local', + connectionId: null, + executionHostId: 'local', + excludeProfileId: 'local-default' + }) + expect(toast.warning).toHaveBeenCalledWith('Project also exists in another profile', { + description: 'Work' + }) }) it('sets up a project on a local host through the project setup API', async () => { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 55429bb6e28..6fc31768e28 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -264,6 +264,51 @@ function getSafeAutoForkSyncKey(repo: Repo): string { return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` } +function formatProjectPresenceProfileNames(profileNames: readonly string[]): string { + const names = [...new Set(profileNames.map((name) => name.trim()).filter(Boolean))] + if (names.length <= 3) { + return names.join(', ') + } + // Why: the "+N more" overflow suffix is user-visible toast copy and must localize. + return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', { + names: names.slice(0, 3).join(', '), + count: names.length - 3 + }) +} + +async function warnIfProjectKnownInAnotherProfile( + repo: Repo, + activeOrcaProfileId: string | null +): Promise { + const findProjectProfiles = window.api.orcaProfiles?.findProjectProfiles + // Why: without a loaded active profile ID the scan cannot exclude the + // current profile and would false-positive on the project just added. + if (!findProjectProfiles || !activeOrcaProfileId) { + return + } + try { + const result = await findProjectProfiles({ + path: repo.path, + connectionId: repo.connectionId ?? null, + executionHostId: getRepoExecutionHostId(repo), + excludeProfileId: activeOrcaProfileId + }) + const description = formatProjectPresenceProfileNames( + result.projects.map((project) => project.profileName) + ) + if (!description) { + return + } + toast.warning( + translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'), + { description } + ) + } catch (err) { + // Why: adding a project should not fail because an advisory profile scan failed. + console.warn('Failed to check project presence in other profiles:', err) + } +} + function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { for (const repo of repos) { if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { @@ -2238,6 +2283,9 @@ export const createRepoSlice: StateCreator = (set, description: repo.displayName } ) + // Why: the design requires the cross-profile advisory for SSH-added + // projects too — the presence lookup already keys on connection/host. + await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) } return repo } catch (err) { diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 75f06ed18e0..ed3e16c75b1 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -43,6 +43,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' import { translate } from '@/i18n/i18n' @@ -91,6 +92,7 @@ export function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index fedadf016e2..be1a2f191ca 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -33,6 +33,7 @@ import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import type { OrcaProfilesSlice } from './slices/orca-profiles' import type { NewIssueDraftSlice } from './slices/new-issue-draft' export type AppState = RepoSlice & @@ -70,4 +71,5 @@ export type AppState = RepoSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice & PinnedTabCloseConfirmSlice & + OrcaProfilesSlice & NewIssueDraftSlice diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 49536f28818..7d42be8ba46 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -46,6 +46,10 @@ import { normalizeWorktreeCardProperties, ONBOARDING_FLOW_VERSION } from '../../../shared/constants' +import { + createDefaultLocalOrcaProfile, + DEFAULT_LOCAL_ORCA_PROFILE_ID +} from '../../../shared/orca-profiles' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' @@ -455,6 +459,15 @@ export function installWebPreloadApi(): void { } function createWebPreloadApi(): Partial { + const webOrcaProfileAuthStatus = () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + configured: false, + state: 'unconfigured' as const, + persistence: 'none' as const, + setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' + }) + return { app: { getIdentity: () => @@ -501,6 +514,58 @@ function createWebPreloadApi(): Partial { displayServer: null }) }, + orcaProfiles: { + list: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + multiProfileUi: false + }), + authStatus: webOrcaProfileAuthStatus, + createLocal: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + profile: createDefaultLocalOrcaProfile(0) + }), + createCloudLinked: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + switchProfile: () => Promise.resolve({ status: 'already-active' }), + transferProject: (args) => + Promise.resolve({ + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: args.repoId, + duplicateRepoId: args.repoId + }), + findProjectProfiles: async () => ({ projects: [] }), + connectCurrent: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + refreshAuth: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + signOutCurrent: async () => ({ + status: 'signed-out', + auth: await webOrcaProfileAuthStatus(), + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)] + }), + selectOrg: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + orgMembersList: async () => ({ status: 'unconfigured' }), + orgMemberInvite: async () => ({ status: 'unconfigured' }), + orgInviteRevoke: async () => ({ status: 'unconfigured' }), + orgMemberChangeRole: async () => ({ status: 'unconfigured' }), + orgMemberRemove: async () => ({ status: 'unconfigured' }) + }, e2e: { getConfig: () => createE2EConfig({}) }, diff --git a/src/shared/orca-profiles.ts b/src/shared/orca-profiles.ts new file mode 100644 index 00000000000..851021a0779 --- /dev/null +++ b/src/shared/orca-profiles.ts @@ -0,0 +1,346 @@ +import { ORCA_BROWSER_PARTITION } from './constants' +import type { ExecutionHostId } from './execution-host' + +export const ORCA_PROFILE_INDEX_SCHEMA_VERSION = 1 +export const DEFAULT_LOCAL_ORCA_PROFILE_ID = 'local-default' +export const DEFAULT_LOCAL_ORCA_PROFILE_NAME = 'Personal' +const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-' + +export type OrcaProfileAvatar = { + kind: 'initials' + initials: string + color: 'neutral' +} + +export type OrcaProfileKind = 'local' | 'cloud-linked' + +export type OrcaProfileCloudSummary = { + cloudProfileId: string + userId: string + email: string + displayName?: string + activeOrgId?: string + activeOrgName?: string + linkedAt: number +} + +export type OrcaCloudOrgSummary = { + orgId: string + name: string + role?: string +} + +export type OrcaCloudCapabilityFlags = Record + +export type OrcaCloudCapabilities = { + flags: OrcaCloudCapabilityFlags + refreshedAt: number +} + +export type OrcaCloudSessionPersistence = 'none' | 'encrypted' | 'memory-only' | 'dev-plaintext' + +export type OrcaProfileAuthState = 'local' | 'unconfigured' | 'connected' | 'reconnect-required' + +export type OrcaProfileAuthStatus = { + activeProfileId: string + configured: boolean + state: OrcaProfileAuthState + persistence: OrcaCloudSessionPersistence + cloud?: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities?: OrcaCloudCapabilities + credentialError?: string + setupMessage?: string +} + +export type OrcaProfileSummary = { + id: string + name: string + avatar: OrcaProfileAvatar + kind: OrcaProfileKind + createdAt: number + updatedAt: number + lastOpenedAt: number + cloud?: OrcaProfileCloudSummary +} + +export type OrcaProfileIndex = { + schemaVersion: number + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type OrcaProfileListState = { + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type OrcaProfileListResult = OrcaProfileListState & { + // Why: gates the full multi-profile switcher UI; default builds show a + // single-profile account menu instead. + multiProfileUi: boolean +} + +export type CreateLocalOrcaProfileArgs = { + name?: string +} + +export type CreateLocalOrcaProfileResult = OrcaProfileListState & { + profile: OrcaProfileSummary +} + +export type CreateCloudLinkedOrcaProfileArgs = { + orgId?: string + name?: string +} + +export type SwitchOrcaProfileArgs = { + profileId: string +} + +export type SwitchOrcaProfileResult = { + status: 'already-active' | 'relaunching' +} + +export type TransferOrcaProfileProjectMode = 'move' | 'copy' + +export type TransferOrcaProfileProjectArgs = { + sourceProfileId: string + targetProfileId: string + repoId: string + mode: TransferOrcaProfileProjectMode +} + +export type FindOrcaProfileProjectsByPathArgs = { + path: string + connectionId?: string | null + executionHostId?: ExecutionHostId | null + excludeProfileId?: string | null +} + +export type OrcaProfileProjectPresence = { + profileId: string + profileName: string + profileKind: OrcaProfileKind + repoId: string + repoName: string +} + +export type FindOrcaProfileProjectsByPathResult = { + projects: OrcaProfileProjectPresence[] +} + +export type TransferOrcaProfileProjectResult = + | { + status: 'transferred' + mode: TransferOrcaProfileProjectMode + sourceProfileId: string + targetProfileId: string + sourceRepoId: string + targetRepoId: string + targetProjectId: string | null + willRelaunch?: boolean + } + | { + status: 'duplicate-target' + sourceProfileId: string + targetProfileId: string + sourceRepoId: string + duplicateRepoId: string + } + +export type ConnectCurrentOrcaProfileResult = + | { + status: 'connected' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'unconfigured' + auth: OrcaProfileAuthStatus + } + | { + status: 'cancelled' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type CreateCloudLinkedOrcaProfileResult = + | { + status: 'created' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + profile: OrcaProfileSummary + } + | { + status: 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type SignOutCurrentOrcaProfileResult = { + status: 'signed-out' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type SelectOrcaProfileOrgArgs = { + orgId: string +} + +export type SelectOrcaProfileOrgResult = + | { + status: 'selected' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type RefreshCurrentOrcaProfileAuthResult = + | { + status: 'refreshed' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'local' | 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +// Why: organization roles are a fixed server-side enum; the desktop UI mirrors +// exactly these three so role selects can't drift from what the API accepts. +export type OrcaOrgRole = 'owner' | 'admin' | 'member' + +export type OrcaOrgMember = { + // Why: null for teammates provisioned server-side who never signed into Orca; + // mutation actions are disabled for them since the API keys on a real userId. + userId: string | null + email: string + displayName?: string + role: OrcaOrgRole +} + +export type OrcaOrgPendingInvite = { + email: string + role: OrcaOrgRole + createdAt: number +} + +export type OrcaOrgMembersRoster = { + members: OrcaOrgMember[] + pendingInvites: OrcaOrgPendingInvite[] + viewerRole: OrcaOrgRole + canManageMembers: boolean +} + +export type OrcaProfileOrgMembersListArgs = { + orgId: string +} + +export type OrcaProfileOrgMemberInviteArgs = { + orgId: string + email: string + role: OrcaOrgRole +} + +export type OrcaProfileOrgInviteRevokeArgs = { + orgId: string + email: string +} + +export type OrcaProfileOrgMemberChangeRoleArgs = { + orgId: string + userId: string + role: OrcaOrgRole +} + +export type OrcaProfileOrgMemberRemoveArgs = { + orgId: string + userId: string +} + +export type OrcaProfileOrgMembersListResult = + | { status: 'ok'; roster: OrcaOrgMembersRoster } + | { status: 'unconfigured' | 'reconnect-required' } + | { status: 'failed'; error: string } + +export type OrcaOrgInviteConflictReason = 'already_member' | 'already_invited' +export type OrcaOrgMutationInvalidReason = 'cannot_change_own_role' | 'cannot_remove_self' + +export type OrcaProfileOrgMemberMutationResult = + | { status: 'ok' } + | { status: 'unconfigured' | 'reconnect-required' | 'forbidden' | 'not-found' } + | { status: 'conflict'; reason: OrcaOrgInviteConflictReason } + | { status: 'invalid'; reason: OrcaOrgMutationInvalidReason } + | { status: 'failed'; error: string } + +export function createDefaultLocalOrcaProfile(now: number): OrcaProfileSummary { + return { + id: DEFAULT_LOCAL_ORCA_PROFILE_ID, + name: DEFAULT_LOCAL_ORCA_PROFILE_NAME, + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: now, + updatedAt: now, + lastOpenedAt: now + } +} + +function profilePartitionHash(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +export function getOrcaProfileBrowserPartitionSegment(profileId: string): string { + const safe = profileId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 48) || 'profile' + return `${safe}-${profilePartitionHash(profileId)}` +} + +export function getOrcaProfileBrowserDefaultPartition(profileId: string): string { + if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + return ORCA_BROWSER_PARTITION + } + return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment(profileId)}-browser-default` +} + +export function getOrcaProfileBrowserSessionPartition( + profileId: string, + browserSessionProfileId: string +): string { + if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + return `${LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX}${browserSessionProfileId}` + } + return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment( + profileId + )}-browser-session-${browserSessionProfileId}` +} diff --git a/tests/e2e/orca-profiles.spec.ts b/tests/e2e/orca-profiles.spec.ts new file mode 100644 index 00000000000..4d8bef3aec6 --- /dev/null +++ b/tests/e2e/orca-profiles.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from './helpers/orca-app' + +// Why: the multi-profile switcher UI is downscoped behind ORCA_MULTI_PROFILE_UI; +// these specs exercise that full UI, so opt the whole file into the flag. +test.use({ launchEnv: { ORCA_MULTI_PROFILE_UI: '1' } }) + +test('opens the profile switcher and profile dialogs', async ({ orcaPage }) => { + const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) + await expect(switcher).toBeVisible() + + await switcher.click() + await expect(orcaPage.getByText('Personal', { exact: true }).first()).toBeVisible() + const manageProfiles = orcaPage.getByRole('menuitem', { name: /Manage profiles/i }) + await expect(manageProfiles).toBeVisible() + await expect(orcaPage.getByRole('menuitem', { name: /New local profile/i })).toBeVisible() + + await manageProfiles.click() + const managementDialog = orcaPage.getByRole('dialog', { name: /Manage profiles/i }) + await expect(managementDialog).toBeVisible() + await expect(managementDialog.getByText(/projects/i).first()).toBeVisible() + + await orcaPage.keyboard.press('Escape') + await expect(managementDialog).toBeHidden() + + await switcher.click() + await orcaPage.getByRole('menuitem', { name: /New local profile/i }).click() + const createDialog = orcaPage.getByRole('dialog', { name: /New local profile/i }) + await expect(createDialog).toBeVisible() + await expect(createDialog.getByPlaceholder(/Profile name/i)).toBeVisible() + await expect(createDialog.getByRole('button', { name: /Create and Switch/i })).toBeVisible() +}) + +test('places the profile switcher in sidebar footer and full-page titlebar', async ({ + orcaPage +}) => { + const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) + const settingsButton = orcaPage.getByRole('button', { name: /^Settings$/ }) + await expect(switcher).toBeVisible() + await expect(settingsButton).toBeVisible() + + const sidebarSwitchBox = await switcher.boundingBox() + const settingsBox = await settingsButton.boundingBox() + const viewport = await orcaPage.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + })) + expect(sidebarSwitchBox).not.toBeNull() + expect(settingsBox).not.toBeNull() + + expect(sidebarSwitchBox!.x + sidebarSwitchBox!.width).toBeLessThanOrEqual(settingsBox!.x + 2) + expect(Math.abs(sidebarSwitchBox!.y - settingsBox!.y)).toBeLessThanOrEqual(2) + expect(sidebarSwitchBox!.y).toBeGreaterThan(viewport.height - 64) + + await settingsButton.click() + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeView)) + .toBe('settings') + + const titlebarSwitchBox = await switcher.boundingBox() + expect(titlebarSwitchBox).not.toBeNull() + expect(titlebarSwitchBox!.x).toBeGreaterThan(viewport.width - 260) + expect(titlebarSwitchBox!.y).toBeLessThan(48) +}) + +test.describe('default single-profile mode', () => { + // Why: no flag — the default build shows no account trigger on a local-only + // (cloud-unconfigured) install. + test.use({ launchEnv: {} }) + + test('hides the account trigger when cloud is unconfigured', async ({ orcaPage }) => { + await expect(orcaPage.getByRole('button', { name: /^Switch profile$/ })).toHaveCount(0) + await expect(orcaPage.getByRole('button', { name: /^Account$/ })).toHaveCount(0) + }) +}) From 7e85db4130ea8b166752407215c71f2230c026b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:44:52 -0700 Subject: [PATCH 111/152] Update mobile 0.0.25 Android download links (#7917) Co-authored-by: Orca --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.pt.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9d712389de8..ee5cf6a0a78 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 6ebc3a03de0..08c63240cc6 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 3d1c0469318..b62d2fd2caa 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 00a409242f2..dc4bc8355fe 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index bb28affe5fc..5d268491ef0 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d8aa12b427c..497e98fe919 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index 378aa6018a9..d4c9d76dd6e 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 294babb624c..06f75346b05 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From ee823b766c9f1881c33f5cb398b0902594b8f907 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:07:05 -0700 Subject: [PATCH 112/152] Move notification status helper to Contents/MacOS (#7931) On macOS 26, UNUserNotificationCenter aborts when executables are run from Contents/Resources because bundleProxyForCurrentProcess returns nil. Moving the orca-notification-status helper to Contents/MacOS next to the main Electron executable ensures proper bundle resolution and avoids immediate crashes. --- config/electron-builder.config.cjs | 13 +++++++++---- .../scripts/electron-builder-config.test.mjs | 16 ++++++++++++++++ .../ipc/notification-authorization-status.ts | 19 +++++++------------ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a418fb49cb5..0148b3cbafd 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -159,7 +159,7 @@ module.exports = { if (context.electronPlatformName === 'darwin') { await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager) await signMacNotificationStatusHelper( - join(resourcesDir, 'orca-notification-status'), + join(resourcesDir, '..', 'MacOS', 'orca-notification-status'), context.packager ) } @@ -251,11 +251,16 @@ module.exports = { from: 'native/computer-use-macos/.build/release/Orca Computer Use.app', to: 'Orca Computer Use.app' }, + featureWallResources + ], + // Why: the notification-status helper must execute from Contents/MacOS — + // on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess + // is nil) for executables launched out of Contents/Resources (#7929). + extraFiles: [ { from: 'native/notification-status-macos/.build/release/orca-notification-status', - to: 'orca-notification-status' - }, - featureWallResources + to: 'MacOS/orca-notification-status' + } ], target: [ { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 46298b376df..850b8810f9b 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -63,6 +63,22 @@ describe('electron-builder config', () => { ) }) + // Why: on macOS 26 UNUserNotificationCenter aborts for executables launched + // from Contents/Resources, so the helper must ship in Contents/MacOS (#7929). + it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => { + expect(electronBuilderConfig.mac.extraFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'native/notification-status-macos/.build/release/orca-notification-status', + to: 'MacOS/orca-notification-status' + }) + ]) + ) + expect(electronBuilderConfig.mac.extraResources).not.toEqual( + expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })]) + ) + }) + it('unpacks the compiled CommonJS boundary with CLI runtime files', () => { expect(electronBuilderConfig.asarUnpack).toEqual( expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**']) diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts index 358d0effce9..bdaba67efae 100644 --- a/src/main/ipc/notification-authorization-status.ts +++ b/src/main/ipc/notification-authorization-status.ts @@ -12,10 +12,11 @@ let cachedHelperPath: string | null | undefined /** * Resolves the bundled notification-status helper binary. * - * Why: the helper must live inside the app bundle — NSBundle resolves the - * process's bundle by walking up from the executable path, and macOS keys - * notification records to that identity. Both dev copies and packaged builds - * place it next to the Electron executable in Contents/MacOS. + * Why: the helper must live in Contents/MacOS next to the Electron executable + * — NSBundle resolves the process's bundle by walking up from the executable + * path, macOS keys notification records to that identity, and macOS 26 aborts + * UNUserNotificationCenter for executables run from Contents/Resources + * (#7929). Dev copies and packaged builds (extraFiles) both place it there. */ function resolveHelperPath(): string | null { if (cachedHelperPath !== undefined) { @@ -25,14 +26,8 @@ function resolveHelperPath(): string | null { cachedHelperPath = null return cachedHelperPath } - // Dev copies place the helper next to the Electron executable; packaged - // builds ship it via extraResources. Both are inside the .app, which is - // what NSBundle resolution requires. - const candidates = [ - join(dirname(process.execPath), HELPER_EXECUTABLE), - ...(process.resourcesPath ? [join(process.resourcesPath, HELPER_EXECUTABLE)] : []) - ] - cachedHelperPath = candidates.find((candidate) => existsSync(candidate)) ?? null + const candidate = join(dirname(process.execPath), HELPER_EXECUTABLE) + cachedHelperPath = existsSync(candidate) ? candidate : null return cachedHelperPath } From 52c7de1adb051aae511b61fb014ec27b898a461c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:11:27 -0700 Subject: [PATCH 113/152] fix(terminal): guard PTY handler unregistration against detach/attach remount races (frozen pane) (#7894) Co-authored-by: Orca --- .../terminal-pane/pty-dispatcher.ts | 8 ++- .../terminal-pane/pty-pre-handler-buffer.ts | 15 +++++ .../terminal-pane/pty-transport.test.ts | 59 +++++++++++++++++++ .../components/terminal-pane/pty-transport.ts | 44 +++++++++++--- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index f27b23318f0..f8333edac3e 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -246,8 +246,12 @@ export function registerEagerPtyBuffer( const exitHandler = (code: number): void => { // Shell died before TerminalPane attached — clean up and notify the store // so the tab's ptyId is cleared and connectPanePty falls through to connect(). - ptyDataHandlers.delete(ptyId) - ptyReplayHandlers.delete(ptyId) + // Identity-guarded like dispose(): never delete a handler a transport has + // since registered for this id. + if (ptyDataHandlers.get(ptyId) === dataHandler) { + ptyDataHandlers.delete(ptyId) + ptyReplayHandlers.delete(ptyId) + } ptyExitHandlers.delete(ptyId) eagerPtyHandles.delete(ptyId) onExit(ptyId, code) diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index 2756f5fc146..23c4661adfd 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -15,6 +15,11 @@ const preHandlerPtyExit = new Map() // and dropping the first setup-script bytes. const PRE_HANDLER_PTY_DATA_MAX_BYTES = 512 * 1024 const PRE_HANDLER_PTY_DATA_MAX_PTYS = 64 +// Why: legit pre-attach windows drain within milliseconds and hold little +// data. Sustained accumulation means a pane lost its data handler (the +// frozen-pane detach/attach race) — leave a breadcrumb for trace capture. +const PRE_HANDLER_PTY_DATA_WARN_BYTES = 64 * 1024 +const warnedLostHandlerPtyIds = new Set() export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyDataMeta): void { const chunk = clampUtf8Tail(data, PRE_HANDLER_PTY_DATA_MAX_BYTES) @@ -42,6 +47,13 @@ export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyD totalBytes -= chunks.shift()?.bytes ?? 0 } preHandlerPtyData.set(ptyId, chunks) + if (totalBytes > PRE_HANDLER_PTY_DATA_WARN_BYTES && !warnedLostHandlerPtyIds.has(ptyId)) { + warnedLostHandlerPtyIds.add(ptyId) + console.warn( + `[pty] ${ptyId}: ${totalBytes} bytes buffered with no registered data handler; ` + + 'the owning pane may have lost its handler to a detach/attach race' + ) + } } export function drainPreHandlerPtyData( @@ -49,6 +61,7 @@ export function drainPreHandlerPtyData( handler: (data: string, meta?: PtyDataMeta) => void ): void { const chunks = preHandlerPtyData.get(ptyId) + warnedLostHandlerPtyIds.delete(ptyId) if (!chunks) { return } @@ -73,9 +86,11 @@ export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) => export function clearPreHandlerPtyData(ptyId: string): void { preHandlerPtyData.delete(ptyId) + warnedLostHandlerPtyIds.delete(ptyId) } export function clearPreHandlerPtyState(ptyId: string): void { preHandlerPtyData.delete(ptyId) preHandlerPtyExit.delete(ptyId) + warnedLostHandlerPtyIds.delete(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index a3c9f51eebc..e68a5f9a2db 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -137,6 +137,65 @@ describe('createIpcPtyTransport', () => { expect(onReplayData).toHaveBeenCalledWith('new replay') }) + it('keeps the live handler when detach() runs after a newer transport attached to the same PTY', async () => { + // Why: pane->tab detach and split-group moves rehome the React subtree, so + // the NEW TerminalPane can attach to the same ptyId BEFORE the old pane's + // unmount detach() runs. An unconditional unregister deletes the live + // handler and the pane freezes with the PTY still alive (frozen-pane bug). + const { createIpcPtyTransport } = await import('./pty-transport') + const receivedByNewPane = vi.fn() + const replayedToNewPane = vi.fn() + const exitSeenByNewPane = vi.fn() + const receivedByOldPane = vi.fn() + + const oldPane = createIpcPtyTransport({}) + await oldPane.connect({ url: '', callbacks: { onData: receivedByOldPane } }) + + const newPane = createIpcPtyTransport({}) + newPane.attach?.({ + existingPtyId: 'pty-1', + callbacks: { + onData: receivedByNewPane, + onReplayData: replayedToNewPane, + onExit: exitSeenByNewPane + } + }) + oldPane.detach?.() + + onData?.({ id: 'pty-1', data: 'live output' }) + onReplay?.({ id: 'pty-1', data: 'replay output' }) + + expect(receivedByNewPane).toHaveBeenCalledWith('live output') + expect(replayedToNewPane).toHaveBeenCalledWith('replay output') + expect(receivedByOldPane).not.toHaveBeenCalled() + + onExit?.({ id: 'pty-1', code: 0 }) + expect(exitSeenByNewPane).toHaveBeenCalledWith(0) + }) + + it('buffers data across a normal detach-then-attach gap and drains it to the next pane', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const receivedByNewPane = vi.fn() + + const oldPane = createIpcPtyTransport({}) + await oldPane.connect({ url: '', callbacks: { onData: vi.fn() } }) + oldPane.detach?.() + + onData?.({ id: 'pty-1', data: 'buffered while detached' }) + expect(receivedByNewPane).not.toHaveBeenCalled() + + const newPane = createIpcPtyTransport({}) + newPane.attach?.({ + existingPtyId: 'pty-1', + callbacks: { onData: receivedByNewPane } + }) + + expect(receivedByNewPane).toHaveBeenCalledWith('buffered while detached') + + onData?.({ id: 'pty-1', data: 'live after reattach' }) + expect(receivedByNewPane).toHaveBeenCalledWith('live after reattach') + }) + it('exposes the connection identity captured at transport creation', async () => { const { createIpcPtyTransport } = await import('./pty-transport') diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 71999b7134b..7df168d7611 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -502,16 +502,41 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }) let storedCallbacks: Parameters[0]['callbacks'] = {} + // Why: pane->tab detach / split-group moves rehome the React subtree, so a + // NEW TerminalPane can attach to the same ptyId before the OLD instance's + // detach() runs. Track the handlers THIS instance registered so unregister + // paths only delete map entries they still own — an unconditional delete + // destroys the live handler and the pane freezes (data diverts into the + // pre-handler buffer forever). + const ownedDataAndReplayHandlers = new Map< + string, + { data: (data: string, meta?: PtyDataMeta) => void; replay: (data: string) => void } + >() + const ownedExitHandlers = new Map void>() + function unregisterPtyHandlers(id: string): void { - ptyDataHandlers.delete(id) - ptyReplayHandlers.delete(id) - ptyExitHandlers.delete(id) - ptyTeardownHandlers.delete(id) + unregisterPtyDataAndStatusHandlers(id) + const ownedExit = ownedExitHandlers.get(id) + if (ownedExit && ptyExitHandlers.get(id) === ownedExit) { + ptyExitHandlers.delete(id) + } + ownedExitHandlers.delete(id) + if (ptyTeardownHandlers.get(id) === clearAccumulatedState) { + ptyTeardownHandlers.delete(id) + } } function unregisterPtyDataAndStatusHandlers(id: string): void { - ptyDataHandlers.delete(id) - ptyReplayHandlers.delete(id) + const owned = ownedDataAndReplayHandlers.get(id) + if (owned) { + if (ptyDataHandlers.get(id) === owned.data) { + ptyDataHandlers.delete(id) + } + if (ptyReplayHandlers.get(id) === owned.replay) { + ptyReplayHandlers.delete(id) + } + } + ownedDataAndReplayHandlers.delete(id) } // Why: shared by connect() and attach() to avoid duplicating title/bell/exit @@ -520,7 +545,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // Why: relay pty.attach sends replay data via a dedicated pty:replay IPC // channel. Route it through onReplayData so the renderer engages the // replay guard and xterm auto-replies do not leak into the shell. - ptyReplayHandlers.set(id, (data) => { + const replayHandler = (data: string): void => { if (ptyId !== id) { return } @@ -529,7 +554,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } else { storedCallbacks.onData?.(data) } - }) + } + ptyReplayHandlers.set(id, replayHandler) const dataHandler = (data: string, meta?: PtyDataMeta): void => { if (ptyId !== id) { return @@ -544,6 +570,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ) } ptyDataHandlers.set(id, dataHandler) + ownedDataAndReplayHandlers.set(id, { data: dataHandler, replay: replayHandler }) drainPreHandlerPtyData(id, dataHandler) } @@ -599,6 +626,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onPtyExit?.(id) } ptyExitHandlers.set(id, exitHandler) + ownedExitHandlers.set(id, exitHandler) // Why: shutdownWorktreeTerminals bypasses the transport layer — it // kills PTYs directly via IPC without calling disconnect()/destroy(). // This teardown callback lets unregisterPtyDataHandlers cancel From 148478e488acccd7a6024c499ec22aee85297bdb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:12:37 +0000 Subject: [PATCH 114/152] release: v1.4.130-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 37d52b48a77..f7b5595e786 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.129-rc.7", + "version": "1.4.130-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 31378023ff40a722b60e8cd094c5d4a46f6dbca4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:51:24 +0000 Subject: [PATCH 115/152] release: v1.4.130 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f7b5595e786..ca303015121 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.130-rc.0", + "version": "1.4.130", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 775fa956ce660a0b223b3fbfa21801976765d007 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:57:42 -0700 Subject: [PATCH 116/152] Remove mobile driver overlay preview file --- previews/mobile-driver-overlay-preview.html | 523 -------------------- 1 file changed, 523 deletions(-) delete mode 100644 previews/mobile-driver-overlay-preview.html diff --git a/previews/mobile-driver-overlay-preview.html b/previews/mobile-driver-overlay-preview.html deleted file mode 100644 index 8c300f3dcad..00000000000 --- a/previews/mobile-driver-overlay-preview.html +++ /dev/null @@ -1,523 +0,0 @@ - - - - - - Mobile Driver Overlay — Orca Preview - - - -
-

Mobile Driver Overlay — production layout preview

-
- - - -
-
- -
-
-
-
- bash — fix-phone-resizing -
- -
- - - - - - - - - - -
-
-
-
- - - - \ No newline at end of file From b32924bf59031cdcd0893e0feb5782cfabc27e0d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:00:58 -0700 Subject: [PATCH 117/152] Chunk offline audio decoding to prevent ONNX SIGTRAP crashes (#7932) --- .../speech/stt-offline-audio-chunker.test.ts | 100 ++++++++++++++++++ src/main/speech/stt-offline-audio-chunker.ts | 92 ++++++++++++++++ src/main/speech/stt-worker.ts | 54 ++++++---- 3 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 src/main/speech/stt-offline-audio-chunker.test.ts create mode 100644 src/main/speech/stt-offline-audio-chunker.ts diff --git a/src/main/speech/stt-offline-audio-chunker.test.ts b/src/main/speech/stt-offline-audio-chunker.test.ts new file mode 100644 index 00000000000..4b90976b2c2 --- /dev/null +++ b/src/main/speech/stt-offline-audio-chunker.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { OFFLINE_DECODE_CHUNK_SECONDS, OfflineAudioChunker } from './stt-offline-audio-chunker' + +// Why: a small rate keeps test arrays tiny while exercising the same +// seconds-based limits used with real 16 kHz audio. +const SAMPLE_RATE = 1000 +const CHUNK_LIMIT = OFFLINE_DECODE_CHUNK_SECONDS * SAMPLE_RATE + +function loudSignal(length: number): Float32Array { + const samples = new Float32Array(length) + for (let i = 0; i < length; i += 1) { + samples[i] = Math.sin(i * 0.3) * 0.8 + } + return samples +} + +describe('OfflineAudioChunker', () => { + it('buffers audio below the chunk limit without emitting chunks', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + expect(chunker.push(loudSignal(CHUNK_LIMIT - 1))).toEqual([]) + }) + + it('emits a bounded chunk once the limit is reached and keeps the remainder', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const total = CHUNK_LIMIT + 500 + + const ready = chunker.push(loudSignal(total)) + const remainder = chunker.flush() + + expect(ready).toHaveLength(1) + expect(ready[0].length).toBeLessThanOrEqual(CHUNK_LIMIT) + expect(ready[0].length).toBeGreaterThan(0) + expect(ready[0].length + (remainder?.length ?? 0)).toBe(total) + }) + + it('never emits a chunk larger than the limit across many small pushes', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const emitted: Float32Array[] = [] + const pushSize = 160 + const pushes = Math.ceil((CHUNK_LIMIT * 3.5) / pushSize) + for (let i = 0; i < pushes; i += 1) { + emitted.push(...chunker.push(loudSignal(pushSize))) + } + const remainder = chunker.flush() + + expect(emitted.length).toBeGreaterThanOrEqual(3) + for (const chunk of emitted) { + expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT) + } + const totalOut = emitted.reduce((sum, c) => sum + c.length, 0) + (remainder?.length ?? 0) + expect(totalOut).toBe(pushes * pushSize) + }) + + it('splits multiple chunks out of one oversized push', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + const ready = chunker.push(loudSignal(CHUNK_LIMIT * 2 + 100)) + + expect(ready.length).toBeGreaterThanOrEqual(2) + for (const chunk of ready) { + expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT) + } + }) + + it('splits at a silent pause near the chunk boundary instead of mid-speech', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const samples = loudSignal(CHUNK_LIMIT + 200) + // Quiet gap 2 s before the limit, inside the 5 s split-search window. + const gapStart = CHUNK_LIMIT - 2 * SAMPLE_RATE + const gapEnd = gapStart + Math.round(0.2 * SAMPLE_RATE) + samples.fill(0, gapStart, gapEnd) + + const [chunk] = chunker.push(samples) + + expect(chunk.length).toBeGreaterThanOrEqual(gapStart) + expect(chunk.length).toBeLessThanOrEqual(gapEnd) + }) + + it('conserves sample values across the split', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const samples = loudSignal(CHUNK_LIMIT + 50) + + const [chunk] = chunker.push(samples) + const remainder = chunker.flush() + + const rejoined = new Float32Array(samples.length) + rejoined.set(chunk, 0) + rejoined.set(remainder!, chunk.length) + expect(rejoined).toEqual(samples) + }) + + it('flush returns null when nothing is buffered', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + expect(chunker.flush()).toBeNull() + expect(chunker.push(new Float32Array(0))).toEqual([]) + expect(chunker.flush()).toBeNull() + }) +}) diff --git a/src/main/speech/stt-offline-audio-chunker.ts b/src/main/speech/stt-offline-audio-chunker.ts new file mode 100644 index 00000000000..291454bf083 --- /dev/null +++ b/src/main/speech/stt-offline-audio-chunker.ts @@ -0,0 +1,92 @@ +// Why: offline recognizers decode a whole buffer per call, and ONNX Runtime's +// arena allocations scale with buffer length. Chromium's allocator shim kills +// the entire app on any single allocation >= 2 GiB (#7925), so audio must be +// decoded in bounded chunks regardless of how long dictation runs. +export const OFFLINE_DECODE_CHUNK_SECONDS = 30 + +// Why: cutting audio mid-word degrades transcription at chunk boundaries. +// Search the tail of each chunk for its quietest window and split at its +// center, so cuts land on real inter-word pauses whenever one exists. The +// window must be pause-sized (~100ms): shorter windows match momentary +// quiet inside a word (e.g. plosive closures) and cut mid-word. +const SPLIT_SEARCH_SECONDS = 5 +const SPLIT_ENERGY_WINDOW_SECONDS = 0.1 + +export class OfflineAudioChunker { + private buffered: Float32Array[] = [] + private bufferedSamples = 0 + private readonly chunkSampleLimit: number + private readonly splitSearchSamples: number + private readonly energyWindowSamples: number + + constructor(sampleRate: number) { + this.chunkSampleLimit = Math.max(1, Math.round(OFFLINE_DECODE_CHUNK_SECONDS * sampleRate)) + this.splitSearchSamples = Math.round(SPLIT_SEARCH_SECONDS * sampleRate) + this.energyWindowSamples = Math.max(1, Math.round(SPLIT_ENERGY_WINDOW_SECONDS * sampleRate)) + } + + /** Buffers samples and returns any full chunks now ready to decode. */ + push(samples: Float32Array): Float32Array[] { + if (samples.length === 0) { + return [] + } + this.buffered.push(samples) + this.bufferedSamples += samples.length + + const ready: Float32Array[] = [] + while (this.bufferedSamples >= this.chunkSampleLimit) { + const combined = this.combineBuffered() + const splitIndex = this.findQuietSplitIndex(combined) + ready.push(combined.slice(0, splitIndex)) + const tail = combined.slice(splitIndex) + this.buffered = tail.length > 0 ? [tail] : [] + this.bufferedSamples = tail.length + } + return ready + } + + /** Returns all remaining buffered audio (any length below the chunk limit). */ + flush(): Float32Array | null { + if (this.bufferedSamples === 0) { + return null + } + const combined = this.combineBuffered() + this.buffered = [] + this.bufferedSamples = 0 + return combined + } + + private combineBuffered(): Float32Array { + if (this.buffered.length === 1) { + return this.buffered[0] + } + const combined = new Float32Array(this.bufferedSamples) + let offset = 0 + for (const chunk of this.buffered) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined + } + + private findQuietSplitIndex(samples: Float32Array): number { + const limit = Math.min(this.chunkSampleLimit, samples.length) + const window = this.energyWindowSamples + const searchStart = Math.max(0, limit - this.splitSearchSamples) + const hop = Math.max(1, Math.floor(window / 2)) + let bestIndex = limit + let bestEnergy = Infinity + for (let start = searchStart; start + window <= limit; start += hop) { + let energy = 0 + for (let i = start; i < start + window; i += 1) { + energy += samples[i] * samples[i] + } + if (energy < bestEnergy) { + bestEnergy = energy + bestIndex = start + Math.floor(window / 2) + } + } + // Why: the split must consume at least one sample or push() would loop forever. + return Math.max(1, bestIndex) + } +} diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 9456f95c8b5..2f4f9b025d4 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -2,6 +2,7 @@ import { parentPort, workerData } from 'node:worker_threads' import { readdirSync } from 'node:fs' import { resampleToRate } from './stt-audio-resample' +import { OfflineAudioChunker } from './stt-offline-audio-chunker' type WorkerMessage = | { @@ -27,7 +28,7 @@ let sherpa: any = null let recognizer: any = null let stream: any = null let isStreaming = false -let offlineBuffer: Float32Array[] = [] +let offlineChunker: OfflineAudioChunker | null = null let offlineSampleRate = 16000 function loadSherpa(): any { @@ -111,7 +112,7 @@ function handleInit(msg: Extract): void { const { modelDir, modelType, streaming, sampleRate, files } = msg isStreaming = streaming - offlineBuffer = [] + offlineChunker = streaming ? null : new OfflineAudioChunker(sampleRate) offlineSampleRate = sampleRate const tokens = resolveTokens(files, modelDir) @@ -203,6 +204,17 @@ function handleInit(msg: Extract): void { } } +// Why: an offline stream is single-use — decode one bounded chunk, then +// recreate the stream so the recognizer is ready for the next chunk. +function decodeOfflineChunk(samples: Float32Array): string { + sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples }) + sherpa.decodeOfflineStream(recognizer, stream) + const resultJson = sherpa.getOfflineStreamResultAsJson(stream) + stream = sherpa.createOfflineStream(recognizer) + const result = JSON.parse(resultJson) + return result?.text?.trim() ?? '' +} + function handleFeed(msg: Extract): void { if (!recognizer || !stream) { return @@ -236,9 +248,17 @@ function handleFeed(msg: Extract): void { sherpa.reset(recognizer, stream) } } else { - // Why: offline recognizers cannot decode incrementally — they need all - // audio buffered first, then decoded in one shot when dictation stops. - offlineBuffer.push(new Float32Array(samples)) + // Why: decoding one unbounded capture in a single call makes ONNX tensor + // sizes scale with dictation length until a >=2 GiB allocation SIGTRAPs + // the whole app (#7925). Decode bounded chunks as they fill instead; + // each consumer already appends multiple 'final' segments per session. + const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? [] + for (const chunk of readyChunks) { + const text = decodeOfflineChunk(chunk) + if (text) { + parentPort?.postMessage({ type: 'final', text }) + } + } } } catch (err) { parentPort?.postMessage({ type: 'error', error: String(err) }) @@ -265,27 +285,15 @@ function handleStop(): void { } stream = sherpa.createOnlineStream(recognizer) } else { - // Why: offline recognizer decodes all audio at once — concatenate - // buffered chunks into a single Float32Array and feed it to the stream. - const totalLength = offlineBuffer.reduce((sum, chunk) => sum + chunk.length, 0) - if (totalLength > 0) { - const combined = new Float32Array(totalLength) - let offset = 0 - for (const chunk of offlineBuffer) { - combined.set(chunk, offset) - offset += chunk.length - } - sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples: combined }) - sherpa.decodeOfflineStream(recognizer, stream) - const resultJson = sherpa.getOfflineStreamResultAsJson(stream) - const result = JSON.parse(resultJson) - const text = result?.text?.trim() + // Why: the remainder is below the chunk limit by construction, so this + // last decode is bounded too. + const remaining = offlineChunker?.flush() + if (remaining && remaining.length > 0) { + const text = decodeOfflineChunk(remaining) if (text) { parentPort?.postMessage({ type: 'final', text }) } } - offlineBuffer = [] - stream = sherpa.createOfflineStream(recognizer) } } catch (err) { parentPort?.postMessage({ type: 'error', error: String(err) }) @@ -298,7 +306,7 @@ function handleTeardown(): void { stream = null recognizer = null sherpa = null - offlineBuffer = [] + offlineChunker = null process.exit(0) } From 6fb3036464e6363284935a93a3b376d76730762f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:02:39 -0700 Subject: [PATCH 118/152] Fix iOS native keyboard dictation in mobile terminal inputs (#7933) --- .../app/h/[hostId]/session/[worktreeId].tsx | 7 ++--- .../terminal-ios-dictation-write-back.test.ts | 25 ++++++++++++++++++ .../use-terminal-live-input-commit.test.ts | 26 +++++++++++++++++++ .../use-terminal-live-input-commit.ts | 8 +++--- 4 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 mobile/src/terminal/terminal-ios-dictation-write-back.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 2119c2da29b..c4701a302db 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -5061,9 +5061,10 @@ export default function SessionScreen() { } style={styles.textInput} value={input} - onChangeText={(text) => - setInput((previousText) => normalizeTerminalTextInput(text, previousText)) - } + // Why: iOS kills an active dictation/IME session when JS + // writes a value that differs from the native field text; + // store the raw field text and normalize at send time. + onChangeText={setInput} placeholder="Type a command…" placeholderTextColor={colors.textMuted} autoCapitalize="none" diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts new file mode 100644 index 00000000000..8dff075d85e --- /dev/null +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +// Why: iOS terminates an active keyboard-dictation (and IME) session whenever +// JS writes a value into the focused field that differs from the native text +// (RN applies it via setTextAndSelection / _setAttributedString). Terminal +// inputs therefore must echo the raw field text in their controlled value and +// apply dash normalization only on the send/mirror path. See stablyai/orca#7925. +describe('terminal iOS dictation write-back', () => { + it('does not write normalized text back into the buffered command input value', () => { + expect(sessionRouteSource).toContain('onChangeText={setInput}') + expect(sessionRouteSource).not.toContain( + 'setInput((previousText) => normalizeTerminalTextInput' + ) + }) + + it('still normalizes the buffered command text at send time', () => { + expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)') + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 3ddb8868270..b759e30d615 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) }) + it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS smart punctuation rewrote "--" into an en dash inside the field + handlers.handleLiveInputChange('a–') + + // Then: writing "a--" back into the controlled value would kill an active + // iOS dictation/IME session, so the capture must keep what iOS produced + expect(captures).toEqual(['a–']) + await vi.waitFor(() => expect(sent).toEqual(['a--'])) + }) + + it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS dictation replaces its hypothesis as recognition refines + handlers.handleLiveInputChange('high') + handlers.handleLiveInputChange('hi there') + + // Then: captures only echo the field; the mirror repairs the PTY with DELs + expect(captures).toEqual(['high', 'hi there']) + await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there'])) + }) + it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index cdabe1bed49..9c707dfbb31 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit() return } - const normalizedText = normalizeTerminalTextInput(text) - setLiveInputCapture(normalizedText) - applyLiveInputMirror(activeHandle, normalizedText) + // Why: iOS kills an active dictation/IME session when JS writes a value + // that differs from the native field text, so the controlled capture must + // echo the field verbatim; only the PTY mirror sees normalized text. + setLiveInputCapture(text) + applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text)) }, [ activeHandle, From 8adfef4ff80e7817b7c7bcd6b8ddf69289078c3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:23:13 +0000 Subject: [PATCH 119/152] 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 67a50e6b5b7..b712f768dce 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.0m + + downloads: 4.1m @@ -15,7 +15,7 @@ downloads downloads - 4.0m - 4.0m + 4.1m + 4.1m From 81e4f0fa682d780f302aa5b8f6fa81a8565ba4c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 9 Jul 2026 11:21:35 -0700 Subject: [PATCH 120/152] fix(sidebar): only show the projects scrollbar when the list overflows (#7845) --- src/renderer/src/assets/main.css | 8 ++++---- src/renderer/src/components/sidebar/WorktreeList.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d9d948b1f2e..44671b28a50 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -473,12 +473,12 @@ scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent; } -/* Keep the sidebar gutter reserved; only the thumb fades in on hover. */ +/* Why: no reserved `scrollbar-gutter` — paired with `overflow-y: auto`, a list + that fits shows no scrollbar (and no Windows scrollbar arrow buttons), so its + rows stay flush with the fixed header controls, matching macOS. The gutter is + only taken while actually scrolling. Thumb still fades in on hover. */ .worktree-sidebar-scrollbar { - /* Why: the scrollbar gutter is paint-only in Chromium; this keeps card - surfaces clear of Orca's resize handle and right-edge sidebar chrome. */ padding-right: 4px; - scrollbar-gutter: stable; scrollbar-color: transparent transparent; } diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index eb1dd5a6018..7b8aaab26d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -3988,7 +3988,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onWheel={markDirectScrollInput} onDragOver={handleWorktreeDragOver} onDrop={handleWorktreeDrop} - className="worktree-sidebar-scrollbar h-full overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px" + className="worktree-sidebar-scrollbar h-full overflow-y-auto overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px" style={WORKTREE_SIDEBAR_SCROLL_STYLE} >
-
+
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} From 1534edc073f11f45f8591bc0d7e744d15bc2e370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:38:26 -0700 Subject: [PATCH 121/152] Separate pane identity from liveness in the tab-agent resolver (fixes OMP tab flicker) (#7860) --- .../terminal-pane/pty-connection.ts | 13 +- .../src/lib/use-tab-agent-pi-identity.test.ts | 305 ++++++++++++++++++ src/renderer/src/lib/use-tab-agent.test.ts | 76 +---- src/renderer/src/lib/use-tab-agent.ts | 167 ++++++---- .../src/runtime/web-session-tabs-sync.ts | 16 +- src/shared/pane-agent-owner.test.ts | 44 +++ src/shared/pane-agent-owner.ts | 58 ++++ 7 files changed, 527 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/lib/use-tab-agent-pi-identity.test.ts create mode 100644 src/shared/pane-agent-owner.test.ts create mode 100644 src/shared/pane-agent-owner.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1ba85b67787..2d534757a41 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -165,6 +165,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess, @@ -1497,11 +1498,13 @@ export function connectPanePty( (entry) => entry.id === deps.tabId ) return ( - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - commandInferredPaneAgent ?? - state.agentStatusByPaneKey[cacheKey]?.agentType + resolvePaneAgentOwner({ + launchAgent: tab?.launchAgent, + startupLaunchAgent: paneStartup?.launchAgent, + initialStatusAgent: paneStartup?.initialAgentStatus?.agent, + commandInferredAgent: commandInferredPaneAgent, + hookAgent: state.agentStatusByPaneKey[cacheKey]?.agentType + }) ?? undefined ) } // Why: the renderer veto (owner evidence beating a Gemini-looking title) must diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts new file mode 100644 index 00000000000..abe494ee174 --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { resolveTabAgentFromSignals } from './use-tab-agent' + +// Pi/OMP share a title-identity group: OMP wraps Pi and emits Pi-compatible +// wrapper title frames. These tests pin how the tab-icon resolver keeps an +// OMP-owned pane on OMP (and a Pi-owned pane on Pi) as those frames arrive, +// including when the pane loses its host-owned launchAgent on a mirrored or +// restored client. +describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { + it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal 1', + hookAgent: null, + siblingHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: 'omp', + launchAgent: 'pi' + }) + ).toBe('pi') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + siblingCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + }) + + it('keeps a restored/mirrored OMP pane on OMP when launchAgent is gone', () => { + // Why: a mirrored or restored OMP pane loses its host-owned launchAgent but + // keeps emitting Pi-compatible wrapper title frames. Durable pane identity + // (last completed hook / hibernated session) must anchor those frames to OMP + // instead of letting them repaint the tab as Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + sleepingSessionAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('does not flap between OMP and Pi as a launchAgent-less pane cycles hooks', () => { + // The flicker: identity must not flip when the live hook row appears/clears. + const withLiveHook = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'omp', + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + const afterHookCleared = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + expect(withLiveHook).toBe('omp') + expect(afterHookCleared).toBe('omp') + }) + + it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => { + // The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays + // Pi even if a stale hibernated OMP record is present. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + sleepingSessionAgent: 'omp', + launchAgent: undefined + }) + ).toBe('pi') + + // An OMP-compatible title on a launchAgent-less Pi pane still resolves to Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + }) +}) + +// The tab icon is a pane's IDENTITY, not its activity state: a hook record +// identifies the pane whether the agent is mid-turn (live) or idle (done). These +// pin that separation so identity can't collapse back into the (non- +// distinguishing) title layer. +describe('resolveTabAgentFromSignals — identity vs liveness', () => { + it('surfaces the focused idle identity from the record, not the title', () => { + // Agent went idle between turns; the title names no agent. Identity still + // comes from the pane's own done-hook record. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('ranks the focused idle identity above a hibernated session and launch bootstrap', () => { + // The agent that actually ran and idled here beats both a hibernation record + // and stale launch intent. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + sleepingSessionAgent: 'claude', + launchAgent: 'codex' + }) + ).toBe('omp') + }) + + it('never lets a title override a live hook (ground truth)', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Claude Code', + hookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('lets a different-group title reclaim a reused idle pane without launch metadata', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Claude Code', + hookAgent: null, + focusedCompletedHookAgent: 'codex', + launchAgent: undefined + }) + ).toBe('claude') + }) + + it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => { + // A launchless pane whose live hook reports Pi resolves to Pi and stays Pi + // when the hook clears (the completed record is Pi too) — no flip to OMP. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + }) + + it('keeps a sibling idle identity when the focused pane returns to its shell', () => { + // Focused pane's local shell-exit evidence must not clear the sibling's idle + // identity — the sibling agent is still there. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + siblingCompletedHookAgent: 'gemini', + launchAgent: undefined + }) + ).toBe('gemini') + }) + + it('does not let a sibling pane re-own the focused pane ambiguous Pi title', () => { + // A split-pane sibling running OMP says nothing about which Pi-variant the + // focused pane runs; the focused pane's own Pi title must stay Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: null, + siblingCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('pi') + }) + + it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => { + // hasObservedAgentSignal starts false for one mount commit; a completed hook + // is itself activity evidence, so the reuse title reclaims immediately + // instead of flashing the prior agent's idle identity. (claude ran+idled, + // then a hookless codex reused the pane and emits its own title.) + const onMount = resolveTabAgentFromSignals({ + hasObservedAgentSignal: false, + isRemote: false, + title: '⠋ Codex', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: undefined + }) + const afterObserved = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Codex', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: undefined + }) + expect(onMount).toBe('codex') + expect(afterObserved).toBe('codex') + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index d4d6031b209..9152159f14b 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -271,80 +271,8 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ Pi', - hookAgent: null, - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ Pi', - hookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: false, - title: '⠋ Pi', - hookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: 'Terminal 1', - hookAgent: null, - siblingHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ OMP', - hookAgent: 'omp', - launchAgent: 'pi' - }) - ).toBe('pi') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: 'zsh', - hookAgent: null, - focusedCompletedHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: false, - title: 'zsh', - hookAgent: null, - siblingCompletedHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - }) + // Pi/OMP identity (shared title-identity group, launchAgent-loss flicker) + // lives in use-tab-agent-pi-identity.test.ts. it('prefers explicit hook identity over a conflicting title mention', () => { expect( diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 50420497155..93051d41d4f 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -12,6 +12,7 @@ import { } from './tab-agent' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' import type { TerminalTab, TuiAgent } from '../../../shared/types' // A shell name, or the tab's neutral default title — where Orca's @@ -83,50 +84,85 @@ export function resolveTabAgentFromSignals(args: { launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null - const explicitTitleAgent = resolveSignalAgentForLaunchOwner( - resolveExplicitTerminalTitleAgentType(args.title), - launchAgent - ) - // Why: explicit titles can override stale launches after activity, but - // Pi-compatible wrapper signals first resolve through the launch owner so - // OMP-created sessions do not repaint as Pi. - // Why: OSC 133;D proved this local pane's foreground is back at the shell, - // so any title-derived identity is stale by definition — a TUI that died - // with a stuck title must not keep painting the tab through the title layer. + // The focused pane's durable agent owner: launch intent first, then this + // pane's own host-stamped hook identity (live or completed), then its + // hibernated session record. It anchors every identity decision for THIS pane + // — it re-owns ambiguous Pi-compatible wrapper titles (OMP emits Pi's frames) + // and keeps a mirrored or restored pane, which dropped its host-owned + // launchAgent, resolving through the durable record instead of the title. + // Why: strictly focused-pane-scoped — a sibling split-pane's identity says + // nothing about which Pi-variant this pane runs, so it must not re-own the + // focused title (it would mislabel a genuine Pi pane as its sibling's OMP). + const owner = resolvePaneAgentOwner({ + launchAgent, + hookAgent: args.hookAgent, + completedHookAgent: args.focusedCompletedHookAgent, + sleepingSessionAgent: args.sleepingSessionAgent + }) as TuiAgent | null + + // A pane's identity comes from its hook record regardless of activity state — + // being idle between turns must not erase which agent is here. We keep the + // live/idle split only because it governs how a title may override identity: a + // LIVE hook is ground truth (a title never overrides it), while an IDLE record + // is durable but a cross-group title can reclaim a pane reused for a different + // agent. Sibling identities normalize against launchAgent only (the tab's + // shared launch intent), never the focused pane's own hook identity. + const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner) + const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) + // Why: OSC 133;D proved this local pane's foreground is back at the shell, so + // the finished agent's idle identity is stale and must stop painting the tab. + // Remote titles lag their runtime, so keep the idle identity there. const processProvesShell = !args.isRemote && args.processShellForeground === true - const titleOverridesLaunch = - launchAgent !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== launchAgent && - args.hasObservedAgentSignal - const titleAgent = processProvesShell - ? null - : titleOverridesLaunch - ? explicitTitleAgent - : launchAgent - ? null - : explicitTitleAgent const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) - // Why: remote pane titles can lag their runtime, so keep the last completed - // hook identity instead of flashing unknown when the title reads as a shell. - const completedHookAgent = + const idleIdentitySuppressed = !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - ? null - : resolveSignalAgentForLaunchOwner( - args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent, - launchAgent - ) - const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent) - const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) - const fallbackHookAgent = siblingHookAgent ?? completedHookAgent + const idleFocusedIdentity = idleIdentitySuppressed + ? null + : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner) + // Why: `idleIdentitySuppressed` is the FOCUSED pane's own exit evidence, so it + // must not clear a sibling split-pane's idle identity — a focused pane back at + // its shell says nothing about whether the sibling's agent has exited. + const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( + args.siblingCompletedHookAgent, + launchAgent + ) + + // The title carries identity in only two roles: (a) a reuse override — it + // names a DIFFERENT-group agent than the pane's known identity, proving the + // pane was reused for a new agent — or (b) a legacy standalone identity when + // the pane has no hook at all. Within the same title-identity group it says + // nothing (OMP wraps Pi and emits identical frames), so the durable record + // wins; re-owning explicitTitleAgent through `owner` enforces that. + const explicitTitleAgent = resolveSignalAgentForLaunchOwner( + resolveExplicitTerminalTitleAgentType(args.title), + owner + ) + const priorIdentity = idleFocusedIdentity ?? launchAgent + // Why: a completed hook is itself proof the pane has shown activity, so it + // arms the reuse override without waiting for `hasObservedAgentSignal` — which + // starts false for one mount commit and would otherwise flash the exited + // agent's idle identity before the new (hookless) agent's title reclaims. + const titleReclaimsReusedPane = + priorIdentity !== null && + explicitTitleAgent !== null && + explicitTitleAgent !== priorIdentity && + (args.hasObservedAgentSignal || hasCompletedHook) + const titleAgent = processProvesShell + ? null + : titleReclaimsReusedPane + ? explicitTitleAgent + : priorIdentity + ? null + : explicitTitleAgent + const launchedAgentExited = resolveLaunchedAgentExitEvidence({ title: args.title, defaultTitle: args.defaultTitle, isRemote: args.isRemote, hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: focusedHookAgent, - siblingHookAgent, + hookAgent: liveFocusedIdentity, + siblingHookAgent: liveSiblingIdentity, hasCompletedHook, processAgent: args.processAgent, processShellForeground: args.processShellForeground @@ -134,53 +170,48 @@ export function resolveTabAgentFromSignals(args: { const activeLaunchAgent = launchedAgentExited ? null : launchAgent const processAgent = args.processAgent ?? null const sleepingSessionAgent = args.sleepingSessionAgent ?? null - // Why: titleAgent ranks ahead of launch/fallback hooks because, once the - // pane has shown activity, a live explicit title is the freshest identity - // signal — it beats a launchAgent gone stale through pane reuse. Before any - // activity, titleAgent is null while launchAgent exists, so launch bootstrap - // still wins the startup window. Process identity (the recognized foreground - // process) is ground truth below hooks — it covers agents that emit neither - // hooks nor titles. sleepingSessionAgent ranks above launch bootstrap: a - // hibernated pane's captured session identity is pane-scoped proof that the - // launch identity went stale (a codex launch later reused for claude), and it - // is the only live signal while the pane is asleep (its PTY, hooks, and - // process are all gone). + // Identity-first precedence. The live focused hook is ground truth while the + // agent works; process identity covers agents with neither hook nor title; the + // title then acts only in its reuse-override / legacy roles; and the pane's + // durable idle identity — the record for an agent that ran here and went idle — + // ranks above the hibernated session, the launch bootstrap, and sibling panes. return ( - focusedHookAgent ?? + liveFocusedIdentity ?? processAgent ?? titleAgent ?? + idleFocusedIdentity ?? sleepingSessionAgent ?? activeLaunchAgent ?? - fallbackHookAgent + liveSiblingIdentity ?? + idleSiblingIdentity ) } /** * Resolve which coding-harness agent a terminal tab is running, for its tab-bar * icon. Identity flows through the same already-computed state as the sidebar - * agent rows — no foreground probing. Layered signals, most-authoritative - * first: + * agent rows — no foreground probing. It is a pane's IDENTITY, kept separate + * from its activity state: a hook record identifies the pane whether the agent + * is working or idle. Identity-first precedence: * - * 1. Hook status — provider identity from native integrations; the live entry - * for the pane, dropped by the same OSC 133 command-finished machinery that - * clears the sidebar row when the process exits. + * 1. Live focused hook — provider identity from native integrations while the + * agent is actively working; ground truth, never overridden by a title. * 2. Process identity — the recognized foreground process, read at OSC 133 * command boundaries (local panes only); covers agents that emit neither - * hooks nor titles, and its shell-foreground mark is title-independent - * exit evidence. - * 3. Sleeping session identity — a hibernated pane's captured session record - * (agent + provider session). Ranks below title but above launchAgent - * because it is pane-scoped proof of reuse: a codex launch later reused for - * claude leaves a claude sleeping record, and while the pane is asleep this - * is the only live identity signal (its PTY, hooks, and process are gone). - * 4. launchAgent — what Orca launched here; instant bootstrap before hooks - * arrive, cleared once hook/process/title evidence shows the launched - * agent exited. - * 5. Title — legacy/unknown-session fallback, and the live override when a pane - * is reused: once the pane has shown activity, a title that explicitly names - * a different agent than launchAgent wins over that stale launch identity. - * Otherwise it is ignored while launchAgent exists, and generic spinner-only - * titles never identify an agent. + * hooks nor titles, and its shell-foreground mark is title-independent exit + * evidence. + * 3. Title — only as a reuse override (it names a DIFFERENT-group agent than the + * pane's known identity, proving reuse) or as a legacy standalone identity + * when the pane has no hook. Within the same title-identity group it carries + * no identity (OMP wraps Pi with identical frames), so the record wins. + * 4. Idle focused identity — the pane's own hook record after the agent went + * idle between turns; the durable answer to "which agent is this" once the + * live hook, process, and any reuse-title are absent. Suppressed on a local + * pane once OSC 133;D proves the agent exited. + * 5. Sleeping session identity — a hibernated pane's captured session record. + * 6. launchAgent — what Orca launched here; the bootstrap before any hook, hook + * record, or process signal exists, cleared once exit evidence shows it left. + * 7. Sibling-pane identity (live, then idle) — split-tab fallback. */ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const focusedHookAgent = useAppStore((s) => diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 47942c67433..e28072d5357 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -48,6 +48,7 @@ import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent' @@ -542,10 +543,12 @@ function buildMirroredTerminalTabs( .filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0) const launchAgent = activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent - const ownerAgent = - launchAgent ?? - activeSurface.agentStatus?.agentType ?? - surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus?.agentType + const ownerAgent = resolvePaneAgentOwner({ + launchAgent, + hookAgent: activeSurface.agentStatus?.agentType, + siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus + ?.agentType + }) const title = normalizeCompatibleAgentTitleForOwner( activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', ownerAgent @@ -624,7 +627,10 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null if (!paneKey) { return null } - const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType + const ownerAgent = resolvePaneAgentOwner({ + launchAgent: surface.launchAgent, + hookAgent: surface.agentStatus.agentType + }) return { ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent), paneKey diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts new file mode 100644 index 00000000000..1d57f0b79c4 --- /dev/null +++ b/src/shared/pane-agent-owner.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { resolvePaneAgentOwner } from './pane-agent-owner' + +describe('resolvePaneAgentOwner', () => { + it('leads with launch intent', () => { + expect( + resolvePaneAgentOwner({ launchAgent: 'omp', hookAgent: 'pi', sleepingSessionAgent: 'claude' }) + ).toBe('omp') + expect(resolvePaneAgentOwner({ startupLaunchAgent: 'codex', hookAgent: 'claude' })).toBe( + 'codex' + ) + expect( + resolvePaneAgentOwner({ initialStatusAgent: 'gemini', commandInferredAgent: 'codex' }) + ).toBe('gemini') + }) + + it('falls through to the durable host-stamped hook identity when launch intent is gone', () => { + // The mirror/restore case: launchAgent dropped, live hook is the anchor. + expect(resolvePaneAgentOwner({ hookAgent: 'omp' })).toBe('omp') + // Live hook cleared: the last completed hook carries the identity. + expect(resolvePaneAgentOwner({ completedHookAgent: 'omp' })).toBe('omp') + // Nothing live at all: the hibernated session record is the last resort. + expect(resolvePaneAgentOwner({ sleepingSessionAgent: 'omp' })).toBe('omp') + }) + + it('ranks live/recent evidence above the hibernated record so a stale record cannot hijack', () => { + expect(resolvePaneAgentOwner({ hookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe('pi') + expect(resolvePaneAgentOwner({ completedHookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe( + 'pi' + ) + }) + + it('prefers focused over sibling evidence at each tier', () => { + expect(resolvePaneAgentOwner({ hookAgent: 'omp', siblingHookAgent: 'pi' })).toBe('omp') + expect( + resolvePaneAgentOwner({ completedHookAgent: 'omp', siblingCompletedHookAgent: 'pi' }) + ).toBe('omp') + }) + + it('returns null when no owner evidence exists', () => { + expect(resolvePaneAgentOwner({})).toBeNull() + expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull() + }) +}) diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts new file mode 100644 index 00000000000..65659a55e59 --- /dev/null +++ b/src/shared/pane-agent-owner.ts @@ -0,0 +1,58 @@ +import type { AgentType } from './agent-status-types' + +/** + * The owner-evidence signals a terminal pane can carry, strongest launch intent + * first, ending in the durable host-stamped identity. Every field is optional so + * each call site passes only what it holds; absent signals are skipped. + */ +export type PaneAgentOwnerSignals = { + /** Tab-scoped launch intent — what Orca launched into this tab. */ + launchAgent?: AgentType | null + /** Never-cleared per-connection launch seed (pane connection only). */ + startupLaunchAgent?: AgentType | null + /** Startup-provided initial agent status (pane connection only). */ + initialStatusAgent?: AgentType | null + /** Agent inferred from a manually typed shell command (pane connection only). */ + commandInferredAgent?: AgentType | null + /** Live focused-pane hook identity — host-stamped, published, mirror-safe. */ + hookAgent?: AgentType | null + /** Live sibling-pane hook identity. */ + siblingHookAgent?: AgentType | null + /** Last completed focused-pane hook — survives the live hook row clearing. */ + completedHookAgent?: AgentType | null + /** Last completed sibling-pane hook. */ + siblingCompletedHookAgent?: AgentType | null + /** Hibernated session record — the final durable identity while a pane sleeps. */ + sleepingSessionAgent?: AgentType | null +} + +/** + * The single authoritative resolver for "which agent owns this pane", shared by + * the tab-icon resolver, the terminal-pane display/renderer owner, and the + * mirrored-tab title owner so they cannot drift apart. + * + * Why this precedence: launch intent is the authoritative bootstrap before any + * process signal exists, so it leads. Once launch metadata is gone — a mirrored + * or restored pane drops the host-owned launchAgent — the owner must fall + * through to a durable pane identity rather than to the raw title, because a + * wrapper agent's title (OMP emits Pi-compatible frames) cannot be told apart + * from the agent it wraps. The host-stamped hook identity is that durable, + * published, mirror-safe anchor; the last completed hook and the hibernated + * session record carry it across the windows where no live hook exists. Ranking + * launch/live-hook above the completed/sleeping records keeps a genuine pane on + * its real agent and stops a stale record from hijacking it. + */ +export function resolvePaneAgentOwner(signals: PaneAgentOwnerSignals): AgentType | null { + return ( + signals.launchAgent ?? + signals.startupLaunchAgent ?? + signals.initialStatusAgent ?? + signals.commandInferredAgent ?? + signals.hookAgent ?? + signals.siblingHookAgent ?? + signals.completedHookAgent ?? + signals.siblingCompletedHookAgent ?? + signals.sleepingSessionAgent ?? + null + ) +} From ff661a7ab24e23692d849129024deef16bc52a2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:39:27 +0000 Subject: [PATCH 122/152] release: v1.4.131-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ca303015121..a0ae48e60ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.130", + "version": "1.4.131-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", 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 123/152] 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 124/152] 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 125/152] 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 126/152] 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 127/152] 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 128/152] 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 129/152] 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 130/152] 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 131/152] 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 132/152] 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 133/152] 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 134/152] 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 135/152] 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 136/152] 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 137/152] 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 138/152] 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 139/152] 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 140/152] 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 141/152] 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 142/152] 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 143/152] 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 144/152] 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 145/152] 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 146/152] 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 147/152] 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; } From d280a7cbe8d3d34178e1f8fa7be6ca0afd881551 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:50:03 -0700 Subject: [PATCH 148/152] Make sidebar reveal always jump instantly instead of smooth-scrolling (#8019) - Removes the `behavior`/`sidebarRevealBehavior` plumbing throughout activation and reveal call sites now that every reveal jumps immediately, eliminating the need to special-case newly created worktrees. - Reworks worktree-sidebar-reveal.ts to center the target row within the viewport and temporarily pad list boundaries so first/last rows can still center instead of clamping to the edge. - Drops the reduced-motion e2e workaround since reveals no longer animate. --- docs/new-worktree-sidebar-reveal.md | 79 ----------------- .../src/components/WorktreeJumpPalette.tsx | 2 +- .../components/sidebar/NonGitFolderDialog.tsx | 5 +- .../sidebar/ProjectAddedDialog.test.tsx | 4 +- .../components/sidebar/ProjectAddedDialog.tsx | 2 +- .../components/sidebar/WorktreeCardAgents.tsx | 4 +- .../src/components/sidebar/WorktreeList.tsx | 85 ++++++++++++------- .../useCreateRepo.default-checkout.test.ts | 4 +- .../src/components/sidebar/useCreateRepo.ts | 2 +- .../worktree-list-scroll-adjustment.test.ts | 68 ++++++++------- .../worktree-scroll-to-current-button.test.ts | 14 +-- .../sidebar/worktree-sidebar-reveal.ts | 62 ++++++++------ .../terminal-agent-session-fork.test.ts | 8 +- .../terminal-agent-session-fork.ts | 4 +- src/renderer/src/hooks/useComposerState.ts | 1 - src/renderer/src/hooks/useIpcEvents.test.ts | 1 - src/renderer/src/hooks/useIpcEvents.ts | 3 - .../src/lib/launch-work-item-direct.ts | 2 - .../worktree-activation-created-agent.test.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 15 +--- .../src/lib/worktree-creation-flow.ts | 1 - .../repos-onboarding-folder-startup.test.ts | 3 +- src/renderer/src/store/slices/repos.ts | 5 +- src/renderer/src/store/slices/ui.test.ts | 3 - src/renderer/src/store/slices/ui.ts | 8 +- .../src/store/slices/worktrees.test.ts | 6 +- src/renderer/src/store/slices/worktrees.ts | 2 +- tests/e2e/worktree-scroll-to-current.spec.ts | 8 +- .../worktree-switch-responsiveness.spec.ts | 4 +- 29 files changed, 161 insertions(+), 248 deletions(-) delete mode 100644 docs/new-worktree-sidebar-reveal.md diff --git a/docs/new-worktree-sidebar-reveal.md b/docs/new-worktree-sidebar-reveal.md deleted file mode 100644 index 75abb7bd647..00000000000 --- a/docs/new-worktree-sidebar-reveal.md +++ /dev/null @@ -1,79 +0,0 @@ -# New Worktree Sidebar Reveal - -## Problem - -Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation. - -Current behavior: - -- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options. -- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`). -- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`. - -Result: off-screen targets animate by default, including freshly created worktrees. - -## Root Cause - -`activateAndRevealWorktree` conflates two intents: - -1. activate existing worktree navigation (smooth reveal is fine); -2. activate a just-created worktree (must jump immediately). - -Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`. - -## Scope and Non-goals - -- Add an opt-in reveal behavior at activation call sites. -- Apply `'auto'` only where the worktree is newly created/added in that flow. -- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in. -- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth). -- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling. - -## Design - -1. Extend `activateAndRevealWorktree` options: - - `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`. -2. In `activateAndRevealWorktree`, call: - - `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided; - - otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged. -3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows: - - `useComposerState` full-create path; - - `useComposerState` quick-create path; - - `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree; - - `launch-work-item-direct`; - - folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path). -4. Keep existing navigation activations smooth, including: - - `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created); - - all existing `activateAndRevealWorktree(...)` callers that do not opt in. -5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`. - -## Correctness Notes and Edge Cases - -- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded. -- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending. -- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution. -- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior. -- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued). -- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations. -- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally. -- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators. - -## Tests - -Add/adjust focused tests in `worktree-activation` coverage: - -- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`; -- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth). - -Add call-site regression tests (recommended, small): - -- one composer create path passes `'auto'`; -- one non-created navigation path stays default (no behavior option). - -## Rollout - -1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`. -2. Update created-worktree callers to pass `'auto'`. -3. Add tests above. -4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`. -5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation. diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 0b18f5f3f09..1cfe10cf9b7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1409,7 +1409,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { skipRestoreFocusRef.current = true // Why: selecting a project or repo group is a sidebar navigation action; // it should reveal the grouping row without activating an arbitrary workspace. - revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true }) + revealSidebarRow(result.rowKey, { highlight: true }) recordFeatureInteraction('cmd-j') closeModal() setSelectedItemId('') diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 5ec3354ec22..33de6b6eb1d 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -82,10 +82,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { onboarding, hadProjectBeforeAdd ) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(startup ? { startup } : {}) - }) + activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } } catch (err) { // This code path calls addRemote directly (not through the store), diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx index f3580472439..66409526770 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx @@ -145,9 +145,7 @@ describe('ProjectAddedDialog', () => { expect(markup).toBe('') expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1') - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder', { - sidebarRevealBehavior: 'auto' - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder') expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx index 6532a39cae8..34f53ffd6ac 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx @@ -81,7 +81,7 @@ export default function ProjectAddedDialog(): null { } const folderWorktree = useAppStore.getState().worktreesByRepo[repoId]?.[0] if (folderWorktree) { - activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(folderWorktree.id) } closeModal() })() diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 7bfda38d231..033cb286dfa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -36,7 +36,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void { if (!(sidebarElement instanceof HTMLElement) || !worktreeOptionElement) { return } - revealElementInScrollContainer(sidebarElement, worktreeOptionElement, 'auto') + revealElementInScrollContainer(sidebarElement, worktreeOptionElement) } type Props = { @@ -238,7 +238,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // Why: defer the reveal scroll out of the expand commit. Running it inline // forces a synchronous sidebar layout that blocks the animation's opening // frames (a visible jump); next-frame keeps the open smooth and the - // ScrollBehavior 'auto' still lands before the height transition finishes. + // instant reveal still lands before the height transition finishes. const handle = requestAnimationFrame(() => { revealCompactAgentCard(compactAgentListRootRef.current) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7b8aaab26d3..34725365a37 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -100,6 +100,7 @@ import { type RenderRow } from './worktree-list-virtual-rows' import { + getElementCenteringScrollPadding, revealElementInScrollContainer, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -287,6 +288,7 @@ import { import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { + getCenteringScrollPadding, getScrollTopToRevealBounds, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -470,31 +472,15 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey? } } -function revealMountedWorktreeElement( - container: HTMLElement, - worktreeId: string, - behavior: ScrollBehavior, - optionId?: string -): HTMLElement | null { - const element = optionId - ? document.getElementById(optionId) - : getMountedWorktreeOptions(worktreeId, container)[0] - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior) ? element : null -} - function revealMountedSidebarRowElement( container: HTMLElement, - rowKey: string, - behavior: ScrollBehavior + rowKey: string ): HTMLElement | null { const element = document.getElementById(getWorktreeOptionId(rowKey)) if (!element || !container.contains(element)) { return null } - return revealElementInScrollContainer(container, element, behavior) ? element : null + return revealElementInScrollContainer(container, element) ? element : null } function getRenderRowSidebarKey(row: RenderRow): string | null { @@ -1336,6 +1322,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [worktreeDragState, setWorktreeDragState] = useState( WORKTREE_ROW_DRAG_INITIAL_STATE ) + const [centeringScrollPadding, setCenteringScrollPadding] = useState<{ + targetKey: string + start: number + end: number + } | null>(null) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) @@ -1985,6 +1976,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), overscan: 10, gap: 6, + paddingStart: centeringScrollPadding?.start ?? 0, + paddingEnd: centeringScrollPadding?.end ?? 0, // Why: the active sticky group header is rendered inside the virtual list, // so TanStack's scroll math needs the same top inset as the exact DOM reveal. scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, @@ -2132,14 +2125,35 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId() } } - const revealedOption = container - ? revealMountedWorktreeElement( - container, - pendingRevealWorktree.worktreeId, - pendingRevealWorktree.behavior, - getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId) - ) - : null + const optionId = getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId) + const centeringTargetKey = `worktree:${optionId ?? pendingRevealWorktree.worktreeId}` + if (centeringScrollPadding && centeringScrollPadding.targetKey !== centeringTargetKey) { + setCenteringScrollPadding(null) + retryExactRevealOnNextFrame() + return + } + const option = optionId + ? document.getElementById(optionId) + : getMountedWorktreeOptions(pendingRevealWorktree.worktreeId, container)[0] + const mountedOption = container && option && container.contains(option) ? option : null + if (container && mountedOption) { + const additionalPadding = getElementCenteringScrollPadding(container, mountedOption) + if (additionalPadding && (additionalPadding.start > 0 || additionalPadding.end > 0)) { + // Why: the first and last virtual rows need temporary boundary space + // or the browser clamps their centered position to the list edge. + setCenteringScrollPadding({ + targetKey: centeringTargetKey, + start: (centeringScrollPadding?.start ?? 0) + additionalPadding.start, + end: (centeringScrollPadding?.end ?? 0) + additionalPadding.end + }) + retryExactRevealOnNextFrame() + return + } + } + const revealedOption = + container && mountedOption && revealElementInScrollContainer(container, mountedOption) + ? mountedOption + : null if (revealedOption) { if (pendingRevealWorktree.highlight) { const revealedRowKey = @@ -2211,6 +2225,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, setRenamingWorktreeId, + centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) @@ -2294,12 +2309,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } const container = scrollRef.current + // Why: only clear stale boundary padding here. While a worktree reveal is + // still pending, clearing would wipe the padding it just accumulated and + // its boundary target could exhaust retries without ever centering. + if (centeringScrollPadding && !pendingRevealWorktree) { + setCenteringScrollPadding(null) + retryExactRevealOnNextFrame() + return + } const revealedElement = container - ? revealMountedSidebarRowElement( - container, - pendingRevealSidebarRow.rowKey, - pendingRevealSidebarRow.behavior - ) + ? revealMountedSidebarRowElement(container, pendingRevealSidebarRow.rowKey) : null if (revealedElement) { if (pendingRevealSidebarRow.highlight) { @@ -2323,6 +2342,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }, [ pendingRevealSidebarRow, + pendingRevealWorktree, repoMap, projectGroups, projectGrouping, @@ -2334,6 +2354,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, clearPendingRevealSidebarRow, + centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) @@ -6657,7 +6678,6 @@ const WorktreeList = React.memo(function WorktreeList({ { target: { type: 'sidebar-row' } } > revealSidebarRow(detail.target.rowKey, { - behavior: 'smooth', highlight: sidebarDetail.highlight !== false }) return @@ -6679,7 +6699,6 @@ const WorktreeList = React.memo(function WorktreeList({ clearFilters() } revealWorktreeInSidebar(currentSidebarWorktreeId, { - behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 51a5ad755b5..83caaaa6d15 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -213,9 +213,7 @@ describe('useCreateRepo default-checkout handoff', () => { kind: 'git' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { - sidebarRevealBehavior: 'auto' - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id) expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedFolder') expect(closeModal).toHaveBeenCalled() expect(mocks.onGitRepoReady).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index abf35db7e66..eb42f347e6f 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -190,7 +190,7 @@ export function useCreateRepo( } const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0] if (folderWorktree) { - activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(folderWorktree.id) } await markOnboardingProjectAdded('addedFolder') closeModal() diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index 924a4edc88a..f9a5a5f62ce 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, + getCenteringScrollPadding, getScrollTopToRevealBounds, resolvePendingSidebarReveal, WORKTREE_SIDEBAR_REVEAL_TOP_INSET, @@ -43,8 +44,11 @@ const makeImportedCardRow = (): Extract - ({ scrollTop, clientHeight }) as HTMLElement +const makeScrollContainer = ( + scrollTop: number, + clientHeight: number, + scrollHeight = 1_000 +): HTMLElement => ({ scrollTop, clientHeight, scrollHeight }) as HTMLElement describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { it('counts record keys once per object reference', () => { @@ -121,64 +125,64 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { }) describe('getScrollTopToRevealBounds', () => { - it('treats the sticky header as occluding the viewport top', () => { - const container = makeScrollContainer(100, 400) + it('requests leading space when the first target would be clamped above center', () => { + const container = makeScrollContainer(0, 400) expect( - getScrollTopToRevealBounds( + getCenteringScrollPadding( container, { - start: 100, - end: 216 - }, - GROUP_HEADER_ROW_HEIGHT - ) - ).toBe(72) - }) - - it('includes extra reveal clearance for the highlight ring', () => { - const container = makeScrollContainer(100, 400) - - expect( - getScrollTopToRevealBounds( - container, - { - start: 100, - end: 216 + start: 34, + end: 74 }, WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBe(66) + ).toEqual({ start: 163, end: 0 }) }) - it('does not scroll when the bounds are below the sticky header', () => { + it('centers a fully visible target', () => { const container = makeScrollContainer(100, 400) expect( getScrollTopToRevealBounds( container, { - start: 128, - end: 244 + start: 300, + end: 400 }, - GROUP_HEADER_ROW_HEIGHT + WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBeNull() + ).toBe(133) }) - it('keeps the viewport bottom independent of the sticky header inset', () => { + it('centers within the area below the sticky header', () => { const container = makeScrollContainer(100, 400) expect( getScrollTopToRevealBounds( + container, + { + start: 300, + end: 400 + }, + GROUP_HEADER_ROW_HEIGHT + ) + ).toBe(136) + }) + + it('requests trailing space when the last target would be clamped below center', () => { + const container = makeScrollContainer(100, 400, 500) + + expect( + getCenteringScrollPadding( container, { start: 430, - end: 520 + end: 470 }, - GROUP_HEADER_ROW_HEIGHT + WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBe(120) + ).toEqual({ start: 0, end: 133 }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 933261f20a4..850fc45704d 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -8,15 +8,17 @@ describe('getScrollTopToRevealBounds', () => { clientHeight }) as HTMLElement - it('scrolls upward to reveal a mounted current workspace card above the viewport', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(60) + it('centers a mounted current workspace card that starts above the viewport', () => { + // Raw result may be negative; boundary reveals convert the deficit into + // temporary paddingStart instead of clamping (see getCenteringScrollPadding). + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(-10) }) - it('scrolls downward to reveal a mounted current workspace card below the viewport', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(140) + it('centers a mounted current workspace card that starts below the viewport', () => { + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(195) }) - it('does not scroll when the current workspace card is already fully visible', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBeNull() + it('recenters a card that is already fully visible', () => { + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBe(92.5) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts index 38b158532af..765efc2eabc 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts @@ -9,6 +9,11 @@ type SidebarRevealBounds = { end: number } +type SidebarCenteringScrollPadding = { + start: number + end: number +} + function getElementScrollBounds(container: HTMLElement, element: Element): SidebarRevealBounds { const containerRect = container.getBoundingClientRect() const elementRect = element.getBoundingClientRect() @@ -22,24 +27,40 @@ export function getScrollTopToRevealBounds( container: HTMLElement, bounds: SidebarRevealBounds, topInset = 0 -): number | null { +): number { const viewportTopInset = Math.max(0, Math.min(container.clientHeight, topInset)) - const viewportTop = container.scrollTop + viewportTopInset - const viewportBottom = container.scrollTop + container.clientHeight - if (bounds.start < viewportTop) { - return bounds.start - viewportTopInset - } - if (bounds.end > viewportBottom) { - return bounds.end - container.clientHeight - } - return null + // Why: the sticky header reduces the usable viewport, so center within the + // visible area below it instead of behind it. + const targetCenter = bounds.start + (bounds.end - bounds.start) / 2 + const viewportCenterOffset = (viewportTopInset + container.clientHeight) / 2 + return targetCenter - viewportCenterOffset } -export function revealElementInScrollContainer( +export function getCenteringScrollPadding( + container: HTMLElement, + bounds: SidebarRevealBounds, + topInset = 0 +): SidebarCenteringScrollPadding { + const desiredScrollTop = getScrollTopToRevealBounds(container, bounds, topInset) + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + return { + start: Math.ceil(Math.max(0, -desiredScrollTop)), + end: Math.ceil(Math.max(0, desiredScrollTop - maxScrollTop)) + } +} + +export function getElementCenteringScrollPadding( container: HTMLElement, element: Element, - behavior: ScrollBehavior -): boolean { + topInset = WORKTREE_SIDEBAR_REVEAL_TOP_INSET +): SidebarCenteringScrollPadding | null { + if (!container.contains(element)) { + return null + } + return getCenteringScrollPadding(container, getElementScrollBounds(container, element), topInset) +} + +export function revealElementInScrollContainer(container: HTMLElement, element: Element): boolean { if (!container.contains(element)) { return false } @@ -48,17 +69,8 @@ export function revealElementInScrollContainer( getElementScrollBounds(container, element), WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - if (nextScrollTop === null) { - return true - } - // Why: honor the user's reduced-motion preference by jumping instantly instead of - // animating a smooth scroll (also makes the reveal deterministic in headless - // environments that never tick the smooth-scroll animation). - const prefersReducedMotion = - typeof window !== 'undefined' && - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true - const resolvedBehavior: ScrollBehavior = - behavior === 'smooth' && prefersReducedMotion ? 'auto' : behavior - container.scrollTo({ top: Math.max(0, nextScrollTop), behavior: resolvedBehavior }) + // Why: sidebar reveal is a focus handoff, so reposition immediately instead + // of making the user track an animated list. + container.scrollTop = Math.max(0, nextScrollTop) return true } diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 8cd870aec13..35686a4abee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -153,9 +153,7 @@ describe('forkAgentSessionFromPane', () => { launchSource: 'terminal_context_menu' }) ) - expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto' - }) + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork') expect(mockToast.success).toHaveBeenCalledWith( 'Top-level session fork opened in a new workspace' ) @@ -346,9 +344,7 @@ describe('forkAgentSessionFromPane', () => { undefined ) expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() - expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto' - }) + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork') expect(mockWriteClipboardText).toHaveBeenCalledWith( expect.stringContaining('Assistant: here is the current plan') ) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 6b36a638749..7f2d62b4782 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -266,7 +266,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro const forkWorktreeId = created.worktree.id if (!fork.agent) { - activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(forkWorktreeId) return copyAgentSessionForkContext(fork) } await preflightForkAgentTrust({ @@ -287,7 +287,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro launchSource: 'terminal_context_menu', ...(launchPlatform ? { launchPlatform } : {}) }) - activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(forkWorktreeId) if (!result) { return copyAgentSessionForkContext(fork) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index d1316b98678..4f4b6ea8b10 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3687,7 +3687,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS startupPlan.launchToken = createBrowserUuid() } const activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c4c51907ccf..3a7683073cc 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4025,7 +4025,6 @@ describe('useIpcEvents CLI-created worktree activation', () => { expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1) expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { setup, - sidebarRevealBehavior: 'auto', notifyHostRuntime: false }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f918744e62c..5657d69a558 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -935,12 +935,10 @@ export function useIpcEvents(): void { // stream and is allowed through this helper separately. return } - const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from // the main process and is not yet in the renderer state. await useAppStore.getState().fetchWorktrees(repoId) - const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) // Why: route through activateAndRevealWorktree so CLI-created // worktrees share the canonical activation path with UI-created // ones. This records the visit in the back/forward history stack @@ -950,7 +948,6 @@ export function useIpcEvents(): void { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), - ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation already came from the host runtime event stream. // Echoing it back as worktree.activate can create a selection loop. notifyHostRuntime: false diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 61f49274edb..7d88276cc51 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -216,7 +216,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom !isTuiAgentEnabled(agentOverride, latestStore.settings?.disabledTuiAgents) ) { activateAndRevealWorktree(worktreeId, { - sidebarRevealBehavior: 'auto', setup: result.setup }) toast.error(unavailableAgentErrorMessage()) @@ -282,7 +281,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom })) const activation = activateAndRevealWorktree(worktreeId, { - sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, ...buildDirectWorkItemStartupOpts(effectiveAgent, startupPlan, launchSource) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 174c1fab32d..184eb7dc7c8 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -299,10 +299,10 @@ describe('activateAndRevealWorktree created agent reopen', () => { revealWorktreeInSidebar }) - const result = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto' }) + const result = activateAndRevealWorktree(worktree.id) expect(result).toEqual({ primaryTabId: expect.any(String) }) - expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id, { behavior: 'auto' }) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) it('asks the host runtime to activate the worktree in the paired web client', async () => { diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 1d94786a345..4f735de5c17 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -25,7 +25,6 @@ import { CLIENT_PLATFORM } from './new-workspace' import { tuiAgentToAgentKind } from './telemetry' import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' -import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { activateWebRuntimeSessionWorktree, @@ -183,7 +182,6 @@ function ensureFolderWorkspaceInitialTerminal( export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: { - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null } @@ -227,11 +225,7 @@ export function activateAndRevealFolderWorkspace( resumeSleepingAgentSessionsForWorktree(workspaceKey) const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) - if (opts?.sidebarRevealBehavior) { - state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) - } else { - state.revealWorktreeInSidebar(workspaceKey) - } + state.revealWorktreeInSidebar(workspaceKey) return { primaryTabId } } @@ -289,7 +283,6 @@ export function activateAndRevealWorktree( setup?: WorktreeSetupLaunch defaultTabs?: WorktreeDefaultTabsLaunch issueCommand?: IssueCommandLaunch - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] notifyHostRuntime?: boolean revealInSidebar?: boolean } @@ -389,11 +382,7 @@ export function activateAndRevealWorktree( // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { - if (opts?.sidebarRevealBehavior) { - state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior }) - } else { - state.revealWorktreeInSidebar(worktreeId) - } + state.revealWorktreeInSidebar(worktreeId) } if (opts?.notifyHostRuntime !== false) { diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 9af1dc61ebe..3e37be6d186 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -222,7 +222,6 @@ async function executeWorktreeCreation( let primaryTabId: string | null if (stillActive) { activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index a18a2733dcc..71e071f91f4 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -56,7 +56,6 @@ describe('repo slice skipped-onboarding folder startup', () => { 1, 'folder-1::/folder', { - sidebarRevealBehavior: 'auto', startup: { command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, @@ -77,7 +76,7 @@ describe('repo slice skipped-onboarding folder startup', () => { expect(worktreeActivation.activateAndRevealWorktree).toHaveBeenNthCalledWith( 2, 'folder-2::/folder', - { sidebarRevealBehavior: 'auto' } + undefined ) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6fc31768e28..4f535acbce6 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -2586,10 +2586,7 @@ export const createRepoSlice: StateCreator = (set, onboarding, hadProjectBeforeAdd ) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(startup ? { startup } : {}) - }) + activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } return repo } catch (err) { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 6b4e88cbc24..202adfe5e31 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -286,7 +286,6 @@ describe('createUISlice agent send target mode', () => { }) expect(store.getState().pendingRevealWorktree).toMatchObject({ worktreeId, - behavior: 'auto', highlight: true }) }) @@ -1098,14 +1097,12 @@ describe('createUISlice hydratePersistedUI', () => { const store = createUIStore() store.getState().revealWorktreeInSidebar('repo1::/feature', { - behavior: 'smooth', highlight: true, beginRename: true }) expect(store.getState().pendingRevealWorktree).toEqual({ worktreeId: 'repo1::/feature', - behavior: 'smooth', highlight: true, beginRename: true }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 04748058e16..7b280b7b100 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -113,14 +113,12 @@ import { translate } from '@/i18n/i18n' export type PendingSidebarWorktreeReveal = { worktreeId: string - behavior: 'auto' | 'smooth' highlight?: boolean beginRename?: boolean } export type PendingSidebarRowReveal = { rowKey: string - behavior: 'auto' | 'smooth' highlight?: boolean } @@ -897,7 +895,6 @@ export type UISlice = { revealWorktreeInSidebar: ( worktreeId: string, options?: { - behavior?: PendingSidebarWorktreeReveal['behavior'] highlight?: boolean beginRename?: boolean } @@ -905,7 +902,6 @@ export type UISlice = { revealSidebarRow: ( rowKey: string, options?: { - behavior?: PendingSidebarRowReveal['behavior'] highlight?: boolean } ) => void @@ -989,7 +985,7 @@ export const createUISlice: StateCreator = (set, get) targets.some((target) => target.status === 'eligible') && (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) ) { - get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) + get().revealWorktreeInSidebar(args.worktreeId, { highlight: true }) } }, closeAgentSendPopoverTargetMode: (id, instanceId) => @@ -2205,7 +2201,6 @@ export const createUISlice: StateCreator = (set, get) set({ pendingRevealWorktree: { worktreeId, - behavior: options?.behavior ?? 'smooth', ...(options?.highlight ? { highlight: true } : {}), ...(options?.beginRename ? { beginRename: true } : {}) } @@ -2214,7 +2209,6 @@ export const createUISlice: StateCreator = (set, get) set({ pendingRevealSidebarRow: { rowKey, - behavior: options?.behavior ?? 'smooth', ...(options?.highlight === false ? {} : { highlight: true }) } }), diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 8929f57594c..8e0cb6e3a30 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6412,7 +6412,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([wt.id], true) expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) - expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(wt.id, { highlight: true }) }) it('reveals on unpin so the viewport follows the row back to its status group', () => { @@ -6427,7 +6427,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([wt.id], false) expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(false) - expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(wt.id, { highlight: true }) }) it('skips a no-op toggle without requesting a reveal', () => { @@ -6492,7 +6492,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true) expect(reveal).toHaveBeenCalledTimes(1) - expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(first.id, { highlight: true }) // Every targeted row is pinned, not just the revealed one, and the // already-pinned row is left untouched. expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d6fb11a315b..9c350fd7652 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3959,7 +3959,7 @@ export const createWorktreeSlice: StateCreator // persistence is async), so the reveal below resolves against a render // where the shortcut row already exists. void get().updateWorktreesMeta(updates) - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + get().revealWorktreeInSidebar(revealWorktreeId, { highlight: true }) }, markWorktreeUnread: (worktreeId) => { diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 19c51005cfe..a3bef0d095b 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -23,11 +23,9 @@ async function prepareSidebarForScrollTest(page: Page): Promise { test.describe('Reveal active workspace button', () => { test.beforeEach(async ({ orcaPage }) => { - // Why: headless Electron under xvfb never ticks a smooth-scroll animation, - // so the reveal's `scrollTo({ behavior: 'smooth' })` would never reach its - // target. Reduced-motion makes the reveal jump instantly (see - // worktree-sidebar-reveal.ts) so the geometry assertions are deterministic. - await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + // Sidebar reveal now always jumps instantly (worktree-sidebar-reveal.ts + // assigns scrollTop directly), so no reduced-motion emulation is needed + // for the geometry assertions to be deterministic. await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) }) diff --git a/tests/e2e/worktree-switch-responsiveness.spec.ts b/tests/e2e/worktree-switch-responsiveness.spec.ts index 45a6e6e6b2f..11b4dea8c97 100644 --- a/tests/e2e/worktree-switch-responsiveness.spec.ts +++ b/tests/e2e/worktree-switch-responsiveness.spec.ts @@ -33,8 +33,8 @@ async function prepareSidebarForSwitchTest(page: Page): Promise<[string, string] if ((state.tabsByWorktree[second.id] ?? []).length === 0) { state.createTab(second.id, undefined, undefined, { pendingActivationSpawn: true }) } - state.revealWorktreeInSidebar(first.id, { behavior: 'auto' }) - state.revealWorktreeInSidebar(second.id, { behavior: 'auto' }) + state.revealWorktreeInSidebar(first.id) + state.revealWorktreeInSidebar(second.id) state.setActiveWorktree(first.id) return [first.id, second.id] }) From 44d5ed04393837d6d04f975abbf76426d0212376 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:54:22 -0700 Subject: [PATCH 149/152] 1.4.131 rc2 release prep (#8020) * Support WSL Codex settings promotion and harden config write-back - Enable settings promotion for WSL runtimes using per-distro baselines. - Create parent directories if missing to prevent promotion ENOENTs. - Keep restrictive permissions (0600) and follow symlinks on promote. - Respect CRLF line endings when inserting keys into CRLF config files. - Skip redundant baseline file writes when settings are unchanged. - Include the release scan report for the 1.4.131-rc2 prep. * Refactor sleeping agent wake flow and fetch rate limits via backend - Background-mount only targeted terminal tabs during passive wake to prevent spawning unnecessary PTYs for unvisited tabs. - Latch edge-triggered wake requests that arrive mid-hibernation and track active claims to prevent double-resuming a provider session. - Query the ChatGPT wham usage backend API directly with fetch for rate limits, avoiding launching Codex or WSL login shells. - Asynchronously probe and serialize WSL auth files with timeouts to prevent synchronous I/O from stalling Electron's main process. - Fix config promotion edge cases such as missing parent directories, dangling symlinks, and atomic write permission widening. * Support WSL dotfile-symlink write-back and lengthen redeem timeout - Preserve symlinked Codex config on WSL by writing through the existing file instead of atomic-rename, since \\wsl$ symlink metadata isn't reliably detected and rename would clobber the link. - Tighten new ~/.codex directory creation to 0700 (holds auth.json). - Give explicit reset-credit redemption a 30s backend timeout instead of the 10s background-poll default, since it's user-triggered. - Read sleeping-agent session state from the worktree's actual execution-host partition instead of always the local one, so the headless-wake check works correctly for SSH-hosted worktrees. - Isolate serve-sim watcher tests from the real $TMPDIR/serve-sim state file to avoid leaking unrelated events. --- docs/STYLEGUIDE.md | 2 +- .../app/h/[hostId]/session/[worktreeId].tsx | 12 +- .../mobile-session-startup-source.test.ts | 2 + .../worktree-activation-result.test.ts | 13 + .../worktree/worktree-activation-result.ts | 8 + prod-release-scan-1.4.131-rc2-output.md | 152 ++++++++++ .../runtime-home-service.test.ts | 112 +++++++- .../codex-accounts/runtime-home-service.ts | 60 +++- src/main/codex/codex-config-mirror.test.ts | 15 + src/main/codex/codex-config-mirror.ts | 50 ++-- .../codex/config-settings-promotion.test.ts | 154 +++++++++- src/main/codex/config-settings-promotion.ts | 139 +++++++-- .../codex/hook-service-wsl-runtime.test.ts | 1 + .../emulator/serve-sim-state-watcher.test.ts | 17 +- src/main/persistence.ts | 6 + .../auth-filesystem-operation.test.ts | 119 ++++++++ .../rate-limits/auth-filesystem-operation.ts | 175 ++++++++++++ .../rate-limits/codex-auth-presence.test.ts | 108 +++++-- src/main/rate-limits/codex-auth-presence.ts | 51 +++- .../rate-limits/codex-fetcher-backend.test.ts | 194 +++++++++++++ src/main/rate-limits/codex-fetcher.test.ts | 36 ++- src/main/rate-limits/codex-fetcher.ts | 214 ++++++++++++-- src/main/runtime/orca-runtime.test.ts | 108 ++++++- src/main/runtime/orca-runtime.ts | 37 ++- src/renderer/src/App.tsx | 16 +- src/renderer/src/app-startup-routing.test.ts | 4 +- src/renderer/src/components/Terminal.tsx | 150 +++++++--- .../TerminalPaneOverlayLayer.tsx | 65 +++-- .../terminal-pane/pty-connection.test.ts | 192 ++++++++++++- .../terminal-pane/pty-connection.ts | 158 +++++++++-- .../use-terminal-pane-lifecycle.ts | 15 +- ...background-terminal-worktree-mount.test.ts | 120 +++++++- .../background-terminal-worktree-mount.ts | 148 ++++++++++ src/renderer/src/constants/terminal.ts | 11 + src/renderer/src/hooks/useIpcEvents.test.ts | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 32 +-- .../launch-agent-background-session.test.ts | 63 ++++- .../lib/launch-agent-background-session.ts | 265 +++++++++--------- ...-agent-session-suppress-navigation.test.ts | 53 ++++ .../src/lib/resume-sleeping-agent-session.ts | 125 +-------- .../src/lib/sleeping-agent-session-launch.ts | 128 +++++++++ ...wake-sleeping-agents-in-background.test.ts | 257 +++++++++++++++-- .../lib/wake-sleeping-agents-in-background.ts | 199 +++++++++++-- .../slices/agent-status-quit-capture.test.ts | 49 ++++ src/renderer/src/store/slices/agent-status.ts | 63 +++-- src/shared/wsl-login-shell-command.test.ts | 58 ++++ 46 files changed, 3393 insertions(+), 567 deletions(-) create mode 100644 mobile/src/worktree/worktree-activation-result.test.ts create mode 100644 mobile/src/worktree/worktree-activation-result.ts create mode 100644 prod-release-scan-1.4.131-rc2-output.md create mode 100644 src/main/rate-limits/auth-filesystem-operation.test.ts create mode 100644 src/main/rate-limits/auth-filesystem-operation.ts create mode 100644 src/main/rate-limits/codex-fetcher-backend.test.ts create mode 100644 src/renderer/src/lib/sleeping-agent-session-launch.ts diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 538b7805d91..d795f74039a 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`: - **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover. - **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces. -- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still. +- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar. Apply one of these to overflow containers; don't write a fourth style. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index da2aae83fce..2043f28e1a9 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -76,6 +76,7 @@ import { import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' +import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { triggerMediumImpact, @@ -2690,6 +2691,11 @@ export default function SessionScreen() { timers.push(setTimeout(fn, ms)) } void (async () => { + const reportActivationOutcome = (response: RpcSuccess | null): void => { + if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) { + showToast('Open Orca on the host to wake sleeping agents.', 3000) + } + } if (client && created !== '1') { // Why: mobile needs host-owned tabs hydrated for this route, but should // not pull other paired clients, especially desktop, into this worktree. @@ -2698,6 +2704,7 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, notifyClients: false }) + .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) } if (disposed) { @@ -2719,12 +2726,13 @@ export default function SessionScreen() { return } void (async () => { - await client + const activationResponse = await client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, notifyClients: false }) .catch(() => null) + reportActivationOutcome(activationResponse?.ok ? activationResponse : null) if (disposed) { return } @@ -2740,7 +2748,7 @@ export default function SessionScreen() { clearTimeout(t) } } - }, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId]) + }, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId]) useEffect(() => { if (!client || connState !== 'connected') { diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index be295594f81..aca17cfb509 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -41,6 +41,8 @@ describe('mobile session startup', () => { expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( startupEffect.indexOf('await fetchSessionTabs()') ) + expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)') + expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") }) it('activates an already-selected pending terminal tab after hydration', () => { diff --git a/mobile/src/worktree/worktree-activation-result.test.ts b/mobile/src/worktree/worktree-activation-result.test.ts new file mode 100644 index 00000000000..11abc5e6970 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { headlessActivationNeedsHostRenderer } from './worktree-activation-result' + +describe('headlessActivationNeedsHostRenderer', () => { + it('only reports the explicit sleeping-agent headless outcome', () => { + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'unsupported-headless' })).toBe( + true + ) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'not-applicable' })).toBe(false) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'requested' })).toBe(false) + expect(headlessActivationNeedsHostRenderer(null)).toBe(false) + }) +}) diff --git a/mobile/src/worktree/worktree-activation-result.ts b/mobile/src/worktree/worktree-activation-result.ts new file mode 100644 index 00000000000..101fc2c6cd0 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.ts @@ -0,0 +1,8 @@ +export function headlessActivationNeedsHostRenderer(result: unknown): boolean { + return Boolean( + result && + typeof result === 'object' && + 'sleepingAgentWake' in result && + result.sleepingAgentWake === 'unsupported-headless' + ) +} diff --git a/prod-release-scan-1.4.131-rc2-output.md b/prod-release-scan-1.4.131-rc2-output.md new file mode 100644 index 00000000000..4e5b0188ccd --- /dev/null +++ b/prod-release-scan-1.4.131-rc2-output.md @@ -0,0 +1,152 @@ +# Prod Release Scan — output.md + +**Verdict:** ready with accepted P1 risk +**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`) +**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute) +**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5 +**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security) +**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers) +**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified + +--- + +## Issue table by PR / commit + +### Has issues + +| Ref | Title | Severity | Issue summary | +|-----|-------|----------|---------------| +| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. | +| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. | +| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. | +| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). | +| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. | +| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). | +| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. | + +### No issues found (clean for this scan) + +| Ref | Title | Notes | +|-----|-------|-------| +| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. | +| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. | +| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). | +| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. | +| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. | +| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. | + +--- + +## Findings first + +### P0 — Release Blocking + +None. + +No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span. + +### P1 — Releasable Only With Explicit Acceptance + +1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY** + `wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`. + *Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`. + +2. **#7906 Mobile wake — double resume of same provider session** + Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record. + *Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`. + +3. **#7906 Mobile wake — race during hibernation kill** + Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching. + *Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier. + +4. **#7906 Perf — whole-worktree background mount** + One passive sleep record mounts every terminal tab in the worktree permanently. + *Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model. + +5. **#7906 Headless mobile pairing — wake no-op** + No renderer ⇒ no wake; headless serve still returns activated. + *Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`. + +6. **#7960 Missing `~/.codex` directory loses first promoted setting** + Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`. + +7. **#7960 Permission widening on promote (privacy)** + Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`. + +8. **#7960 WSL never gets promotion** + WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only. + *Evidence:* `runtime-home-service.ts:139-150,225-235`. + +### P2 — Nice To Have + +- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`). +- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys. +- **#7977** Auto-enabled experimental card cohort not rolled back. +- **#7845** STYLEGUIDE scrollbar gutter text out of date. + +--- + +## Clean matrix + +| Area | Status | +|------|--------| +| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. | +| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. | +| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. | +| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. | +| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. | +| Git providers / shared contracts | Not materially touched. | +| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. | + +--- + +## Verification + +| Check | Result | +|-------|--------| +| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed | +| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign | +| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff | +| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` | +| Missing parent repro | `ENOENT` | +| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) | +| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment | + +--- + +## Orchestration provenance + +| Worker | Task | Terminal | Report | +|--------|------|----------|--------| +| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` | +| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` | +| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` | +| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` | +| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` | +| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` | + +All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch. + +--- + +## Residual risk + +- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms. +- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged. +- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk. +- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven. + +--- + +## Bottom line + +**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable: + +1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab. +2. Deduplicate in-place hibernation wake vs generic resume for the same claim key. +3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`. +4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported). + +Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2. diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 7a1facf1420..9447e81ef75 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -533,6 +533,74 @@ describe('CodexRuntimeHomeService', () => { } }) + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve()) + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml') + mkdirSync(join(wslHome, '.codex'), { recursive: true }) + writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8') + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + // First launch seeds the runtime config and records the per-distro baseline. + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') + expect(existsSync(baselinePath)).toBe(true) + + // A direct WSL Codex edit wins and is mirrored into Orca's runtime before + // the baseline advances, so later in-Orca changes remain promotable. + const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') + writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') + + // Codex now persists a /model change inside Orca's reconciled runtime. + writeFileSync( + runtimeConfigPath, + readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), + 'utf-8' + ) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('bridges WSL history from a configured per-distro source-home override', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -1892,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + it('reads WSL system-default rate limits from the live system home without materializing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -1910,10 +1978,21 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + const syncWslRuntime = vi.spyOn( + service as unknown as { + syncWslRuntimeForCurrentSelection: (target: { + runtime: 'wsl' + wslDistro?: string | null + }) => string | null + }, + 'syncWslRuntimeForCurrentSelection' ) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const expectedHome = join(wslHome, '.codex') + + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -2041,20 +2120,23 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + const externallyRefreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-refreshed', + 3_000 + ) + writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8') + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + systemCodexHomePath + ) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( + externallyRefreshedAuth + ) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 67120f15e53..754a0f1113a 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -139,9 +139,9 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const runtimeHomePath = - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } @@ -222,13 +222,33 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, '.codex') : null } + // Why: WSL needs the same promote-then-mirror transaction as host. It keeps + // in-Orca changes while reconciling a newer external ~/.codex edit before + // advancing the per-distro baseline. Only runs on a materialized runtime. + private syncWslConfigSettingsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + } + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - return ( - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) - ) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) + return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() @@ -503,6 +523,32 @@ export class CodexRuntimeHomeService { return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null } + private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca (login, logout, + // token refresh). Read its real home directly so a cached runtime copy + // cannot stay stale; filesystem probing in the fetcher is asynchronous. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll. Account + // changes sync explicitly, so repeated resolution must stay path-only + // instead of blocking main on UNC reads and a wsl.exe migration probe. + return cachedRuntimeHomePath + } + } + return this.syncWslRuntimeForCurrentSelection(target) + } + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 416ce9af78b..885e2030548 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -25,6 +25,7 @@ vi.mock('node:os', async () => { import { prepareSystemConfigForFreshRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' @@ -402,6 +403,20 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) describe('prepareSystemConfigForFreshRuntimeMirror', () => { + it('uses the Linux-side directory for WSL UNC source homes', () => { + const sourceDir = resolveCodexConfigMirrorSourceDirectory( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + + expect(sourceDir).toBe('/home/alice/.codex') + expect( + prepareSystemConfigForFreshRuntimeMirror( + 'model_instructions_file = "instructions.md"\n', + sourceDir + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + }) + it('rewrites relative paths against a Linux-side home and strips hook trust', () => { const prepared = prepareSystemConfigForFreshRuntimeMirror( [ diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 605f2cd5f24..422b0e79280 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,9 +3,11 @@ 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 { parseWslUncPath } from '../../shared/wsl-paths' import { promoteCodexRuntimeSettingsToSystem, - snapshotCodexRuntimeSettingsBaseline + snapshotCodexRuntimeSettingsBaseline, + type CodexSettingsPromotionHomes } from './config-settings-promotion' import { createTomlLineScanState, @@ -14,33 +16,37 @@ import { updateTomlLineScanState } from './config-toml-line-scan' -function getRuntimeCodexConfigTomlPath(): string { - return join(getOrcaManagedCodexHomePath(), 'config.toml') -} - -function getSystemCodexConfigTomlPath(): string { - return join(getSystemCodexHomePath(), 'config.toml') -} - -export function syncSystemConfigIntoManagedCodexHome(): void { +export function syncSystemConfigIntoManagedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): 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() + if (!promoteCodexRuntimeSettingsToSystem(homes)) { + // Why: mirroring after a failed write-back would erase the runtime change; + // leave both runtime and its old baseline intact so the next launch retries. + return + } try { - syncSystemConfigIntoManagedCodexHomeUnsafe() + syncSystemConfigIntoManagedCodexHomeUnsafe(homes) } 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() + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath) } -function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { - const systemConfigPath = getSystemCodexConfigTomlPath() - const runtimeConfigPath = getRuntimeCodexConfigTomlPath() +function syncSystemConfigIntoManagedCodexHomeUnsafe({ + runtimeHomePath, + systemHomePath +}: CodexSettingsPromotionHomes): void { + const systemConfigPath = join(systemHomePath, 'config.toml') + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) if (!systemConfigExists && !runtimeConfigExists) { @@ -48,18 +54,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, - prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath)) + prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) ) return } - const systemConfig = prepareSystemConfigForRuntimeMirror( - rawSystemConfig, - dirname(systemConfigPath) - ) + const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) if (mergedConfig !== runtimeConfig) { @@ -67,6 +71,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } } +export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string { + return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml')) +} + function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { return rewriteRelativePathConfigValues( normalizeDeprecatedCodexHookFeatureFlag(config), diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index f81866d8751..f6ceabc362f 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -1,11 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' -const { homedirMock } = vi.hoisted(() => ({ - homedirMock: vi.fn<() => string>() +const { homedirMock, promotionTestState } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + promotionTestState: { failAtomicWrite: false } })) vi.mock('node:os', async (importOriginal) => { @@ -16,6 +30,19 @@ vi.mock('node:os', async (importOriginal) => { } }) +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (promotionTestState.failAtomicWrite) { + throw new Error('injected atomic write failure') + } + return actual.writeFileAtomically(...args) + } + } +}) + import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' import { upsertTopLevelSettingsInContent } from './config-settings-promotion' @@ -29,6 +56,7 @@ beforeEach(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) + promotionTestState.failAtomicWrite = false // 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) { @@ -188,7 +216,9 @@ describe('codex settings write-back promotion', () => { }) it('creates ~/.codex/config.toml when a user without one changes a setting', () => { - mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + // Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the + // config nor its directory, and promotion must create both. + expect(existsSync(join(tmpHome, '.codex'))).toBe(false) syncSystemConfigIntoManagedCodexHome() expect(existsSync(baselinePath())).toBe(true) @@ -266,6 +296,110 @@ describe('codex settings write-back promotion', () => { expect(readSystemConfig()).toBe('model = "o4"\n') }) + + it.skipIf(process.platform === 'win32')( + 'preserves a restrictive mode on the existing ~/.codex/config.toml', + () => { + writeSystemConfig('model = "gpt-5"\n') + chmodSync(systemConfigPath(), 0o600) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + } + ) + + it.skipIf(process.platform === 'win32')( + 'creates a new ~/.codex/config.toml with owner-only permissions', + () => { + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + // The created ~/.codex itself is owner-only — it will also hold + // auth.json once the user signs in. + expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700) + } + ) + + it.skipIf(process.platform === 'win32')( + 'writes through a symlinked config.toml without replacing the link', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles-config.toml') + writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'preserves a dangling config.toml symlink and creates its target', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it('inserts a missing key into a CRLF config with CRLF endings', () => { + writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n' + ) + }) + + it('does not rewrite an unchanged settings baseline', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + const past = new Date(Date.now() - 120_000) + utimesSync(baselinePath(), past, past) + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000) + }) + + it('keeps the old baseline and retries after a transient promotion failure', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8') + simulateCodexSettingWrite('model', '"o4"') + + promotionTestState.failAtomicWrite = true + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toBe('model = "o4"\n') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) + + promotionTestState.failAtomicWrite = false + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) }) describe('upsertTopLevelSettingsInContent', () => { @@ -293,4 +427,16 @@ describe('upsertTopLevelSettingsInContent', () => { ) ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') }) + + it('inserts with CRLF endings into CRLF content', () => { + expect( + upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n') + }) + + it('appends with CRLF to a CRLF preamble-only file', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\r\nmodel = "x"\r\n') + }) }) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 934d57ed39e..7e8552bb9b6 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -1,6 +1,16 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { createTomlLineScanState, @@ -103,9 +113,10 @@ function readTopLevelSettingValues(configPath: string): Map() + while (!visited.has(currentPath)) { + visited.add(currentPath) + try { + if (!lstatSync(currentPath).isSymbolicLink()) { + return currentPath + } + currentPath = resolve(dirname(currentPath), readlinkSync(currentPath)) + } catch { + return currentPath + } + } + // Why: replacing any link in a cycle would destroy dotfile-manager state; + // abort promotion and leave the runtime/baseline intact for manual repair. + throw new Error(`Codex config symlink cycle at ${linkPath}`) } export function upsertTopLevelSettingsInContent( @@ -215,16 +310,17 @@ export function upsertTopLevelSettingsInContent( state = updateTomlLineScanState(state, line) } + // Why: CRLF configs keep a trailing \r after the split; new lines must use + // the file's existing endings or a Windows-owned config becomes mixed-EOL. + const usesCrlf = content.includes('\r\n') 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) + insertions.push(usesCrlf ? `${rendered}\r` : rendered) } } if (insertions.length > 0) { @@ -233,10 +329,13 @@ export function upsertTopLevelSettingsInContent( insertAt -= 1 } if (insertAt === preambleEnd && preambleEnd < lines.length) { - insertions.push('') + insertions.push(usesCrlf ? '\r' : '') } lines.splice(insertAt, 0, ...insertions) } const result = lines.join('\n') - return result.endsWith('\n') || result.length === 0 ? result : `${result}\n` + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` } diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 2bbf2bdd20d..20accbb5859 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -213,6 +213,7 @@ describe('Codex WSL runtime hook install', () => { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}`, WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_ENDPOINT: '', ORCA_AGENT_HOOK_PORT: '43210', ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_PANE_KEY: 'pane-1', diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 006531dff20..bf9d7a61fa1 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s const TEST_UDID = '11111111-2222-3333-4444-555555555555' +let isolatedStateDirSeq = 0 +// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state +// file from an actual simulator session would leak extra events into tests +// that only exercise PTY ingestion. Point at a dir that never exists. +function createIsolatedWatcher(): ServeSimStateWatcher { + isolatedStateDirSeq += 1 + return new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) + }) +} + async function waitForEvent( events: ServeSimStateDetectedEvent[], predicate: (event: ServeSimStateDetectedEvent) => boolean @@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => { }) it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => { }) it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => { }) it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const firstPayload = JSON.stringify({ device: TEST_UDID, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0af7920b1a2..5ef81f071bd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3094,6 +3094,12 @@ export class Store { ), settings: { ...defaults.settings, + // Why (#7977): a persisted experimentalNewWorktreeCardStyle:true is + // kept even though the default is now false. The v1.4.130 open- + // onboarding auto-default wrote the same plain boolean as a real + // opt-in, so a rollback migration would also revert genuine opt-ins; + // product intent was only to change the default, and the setting + // stays user-toggleable. ...stripLegacyTerminalScrollbackBytes(parsed.settings), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. diff --git a/src/main/rate-limits/auth-filesystem-operation.test.ts b/src/main/rate-limits/auth-filesystem-operation.test.ts new file mode 100644 index 00000000000..92099fa2dcc --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAuthFilesystemOperation } from './auth-filesystem-operation' + +describe('createAuthFilesystemOperation', () => { + it('serializes WSL aliases by distro and drops an abandoned queued read', async () => { + let resolveFirst!: (value: string) => void + const firstRaw = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + const secondRaw = vi.fn(async () => 'second') + const first = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + firstRaw + ) + const second = createAuthFilesystemOperation( + '\\\\WSL$\\ubuntu\\home\\alice\\managed\\auth.json', + secondRaw + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const firstWait = first.wait(firstController.signal) + const secondWait = second.wait(secondController.signal) + await Promise.resolve() + await Promise.resolve() + expect(firstRaw).toHaveBeenCalledOnce() + expect(secondRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('queued caller expired') + secondController.abort(queuedAbort) + await expect(secondWait).rejects.toBe(queuedAbort) + + resolveFirst('first') + await expect(firstWait).resolves.toBe('first') + await expect(second.result).rejects.toBe(queuedAbort) + expect(secondRaw).not.toHaveBeenCalled() + }) + + it('allows different WSL distros to probe concurrently', async () => { + const ubuntuRaw = vi.fn(async () => 'ubuntu') + const debianRaw = vi.fn(async () => 'debian') + const ubuntu = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + ubuntuRaw + ) + const debian = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Debian\\home\\alice\\.codex\\auth.json', + debianRaw + ) + const controller = new AbortController() + + await expect( + Promise.all([ubuntu.wait(controller.signal), debian.wait(controller.signal)]) + ).resolves.toEqual(['ubuntu', 'debian']) + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + }) + + it('does not start an operation for an already-aborted waiter', async () => { + const raw = vi.fn(async () => 'unexpected') + const operation = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + raw + ) + const controller = new AbortController() + const abortError = new Error('already expired') + controller.abort(abortError) + + await expect(operation.wait(controller.signal)).rejects.toBe(abortError) + await expect(operation.result).rejects.toBe(abortError) + expect(raw).not.toHaveBeenCalled() + }) + + it('caps cross-distro operations and never starts an expired queued probe', async () => { + let resolveUbuntu!: (value: string) => void + let resolveDebian!: (value: string) => void + const ubuntuRaw = vi.fn( + () => + new Promise((resolve) => { + resolveUbuntu = resolve + }) + ) + const debianRaw = vi.fn( + () => + new Promise((resolve) => { + resolveDebian = resolve + }) + ) + const fedoraRaw = vi.fn(async () => 'fedora') + const ubuntu = createAuthFilesystemOperation('\\\\wsl$\\Ubuntu\\a\\auth.json', ubuntuRaw) + const debian = createAuthFilesystemOperation('\\\\wsl$\\Debian\\b\\auth.json', debianRaw) + const fedora = createAuthFilesystemOperation('\\\\wsl$\\Fedora\\c\\auth.json', fedoraRaw) + const ubuntuController = new AbortController() + const debianController = new AbortController() + const fedoraController = new AbortController() + + const ubuntuWait = ubuntu.wait(ubuntuController.signal) + const debianWait = debian.wait(debianController.signal) + const fedoraWait = fedora.wait(fedoraController.signal) + await Promise.resolve() + await Promise.resolve() + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + expect(fedoraRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('global queue expired') + fedoraController.abort(queuedAbort) + await expect(fedoraWait).rejects.toBe(queuedAbort) + await expect(fedora.result).rejects.toBe(queuedAbort) + + resolveUbuntu('ubuntu') + resolveDebian('debian') + await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) + expect(fedoraRaw).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts new file mode 100644 index 00000000000..228234e92c0 --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.ts @@ -0,0 +1,175 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2 +const activeWslOperationDistros = new Set() +const queuedWslOperations: QueuedWslOperation[] = [] +let activeWslOperationCount = 0 + +type QueuedWslOperation = { + distroKey: string + neededSignal: AbortSignal + operation: () => Promise + resolve: (value: T) => void + reject: (error: unknown) => void + onAbort: () => void + state: 'queued' | 'running' | 'settled' +} + +function getAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('Auth filesystem operation aborted') +} + +function finishWslOperation(task: QueuedWslOperation, settle: () => void): void { + if (task.state !== 'running') { + return + } + task.state = 'settled' + activeWslOperationCount -= 1 + activeWslOperationDistros.delete(task.distroKey) + settle() + pumpWslOperations() +} + +function pumpWslOperations(): void { + while (activeWslOperationCount < MAX_CONCURRENT_WSL_AUTH_OPERATIONS) { + const nextIndex = queuedWslOperations.findIndex( + (task) => !activeWslOperationDistros.has(task.distroKey) + ) + if (nextIndex === -1) { + return + } + const task = queuedWslOperations.splice(nextIndex, 1)[0] + if (!task || task.state !== 'queued') { + continue + } + task.neededSignal.removeEventListener('abort', task.onAbort) + if (task.neededSignal.aborted) { + task.state = 'settled' + task.reject(getAbortReason(task.neededSignal)) + continue + } + task.state = 'running' + activeWslOperationCount += 1 + activeWslOperationDistros.add(task.distroKey) + void Promise.resolve() + .then(() => { + if (task.neededSignal.aborted) { + throw getAbortReason(task.neededSignal) + } + return task.operation() + }) + .then( + (value) => finishWslOperation(task, () => task.resolve(value)), + (error: unknown) => finishWslOperation(task, () => task.reject(error)) + ) + } +} + +function scheduleWslAuthFilesystemOperation( + distroKey: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + return new Promise((resolve, reject) => { + const task: QueuedWslOperation = { + distroKey, + neededSignal, + operation, + resolve, + reject, + state: 'queued', + onAbort: () => { + if (task.state !== 'queued') { + return + } + task.state = 'settled' + const index = queuedWslOperations.indexOf(task as QueuedWslOperation) + if (index !== -1) { + queuedWslOperations.splice(index, 1) + } + reject(getAbortReason(neededSignal)) + pumpWslOperations() + } + } + neededSignal.addEventListener('abort', task.onAbort, { once: true }) + queuedWslOperations.push(task as QueuedWslOperation) + queueMicrotask(pumpWslOperations) + }) +} + +function scheduleAuthFilesystemOperation( + authPath: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + const wslInfo = parseWslUncPath(authPath) + if (!wslInfo) { + return Promise.resolve().then(() => { + if (neededSignal.aborted) { + throw getAbortReason(neededSignal) + } + return operation() + }) + } + // Why: a few disconnected distros must not occupy libuv's entire default + // filesystem pool. Distro serialization also folds wsl$/wsl.localhost and + // case aliases without forcing healthy local auth reads through the queue. + return scheduleWslAuthFilesystemOperation( + wslInfo.distro.trim().toLowerCase(), + neededSignal, + operation + ) +} + +export type SharedAuthFilesystemOperation = { + result: Promise + wait: (signal: AbortSignal) => Promise +} + +/** + * Shares one raw operation with all callers for an auth path. WSL paths also + * serialize by normalized distro because one stuck UNC request per account can + * otherwise exhaust libuv's filesystem threadpool. + */ +export function createAuthFilesystemOperation( + authPath: string, + operation: () => Promise +): SharedAuthFilesystemOperation { + const neededController = new AbortController() + const waiters = new Set() + let settled = false + const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation) + const markSettled = (): void => { + settled = true + } + void result.then(markSettled, markSettled) + + return { + result, + wait(signal) { + if (signal.aborted) { + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + return Promise.reject(getAbortReason(signal)) + } + + const waiter = Symbol('auth-filesystem-waiter') + waiters.add(waiter) + let onAbort: (() => void) | null = null + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(getAbortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + }) + return Promise.race([result, aborted]).finally(() => { + if (onAbort) { + signal.removeEventListener('abort', onAbort) + } + waiters.delete(waiter) + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + }) + } + } +} diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts index f4e10fa8693..95854350f7f 100644 --- a/src/main/rate-limits/codex-auth-presence.test.ts +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -1,13 +1,13 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { existsSyncMock, homedirMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), +const { accessMock, homedirMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), homedirMock: vi.fn() })) -vi.mock('node:fs', () => ({ - existsSync: existsSyncMock +vi.mock('node:fs/promises', () => ({ + access: accessMock })) vi.mock('node:os', () => ({ @@ -33,33 +33,101 @@ describe('codexAuthExists', () => { } }) - it('checks an explicit managed-account home first', () => { - existsSyncMock.mockReturnValue(true) + it('checks an explicit managed-account home first', async () => { + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists('/managed/home')).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) + await expect(codexAuthExists('/managed/home')).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) }) - it('falls back to CODEX_HOME when no home is provided', () => { + it('falls back to CODEX_HOME when no home is provided', async () => { process.env.CODEX_HOME = '/custom/codex' - existsSyncMock.mockReturnValue(true) + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists()).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) }) - it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => { - existsSyncMock.mockReturnValue(false) + it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => { + accessMock.mockRejectedValue(new Error('ENOENT')) - expect(codexAuthExists()).toBe(false) - expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(false) + expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) }) - it('returns false instead of throwing when the fs check fails', () => { - existsSyncMock.mockImplementation(() => { - throw new Error('EACCES') + it('returns false instead of throwing when the fs check fails', async () => { + accessMock.mockRejectedValue(new Error('EACCES')) + + await expect(codexAuthExists('/managed/home')).resolves.toBe(false) + }) + + it('stops waiting for a stalled filesystem check when the caller aborts', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const controller = new AbortController() + + const result = codexAuthExists('/managed/home', { signal: controller.signal }) + await Promise.resolve() + await Promise.resolve() + controller.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + }) + + it('bounds a stalled filesystem check even without a caller signal', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal) + + const result = codexAuthExists('/managed/home') + expect(timeout).toHaveBeenCalledWith(5_000) + await Promise.resolve() + await Promise.resolve() + timeoutController.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + timeout.mockRestore() + }) + + it('shares one stalled UNC probe across concurrent callers', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: firstController.signal + }) + const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: secondController.signal }) - expect(codexAuthExists('/managed/home')).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(accessMock).toHaveBeenCalledTimes(1) + firstController.abort() + secondController.abort() + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + + resolveAccess() + await Promise.resolve() }) }) diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index f972fcecbc0..8ebe81bd9be 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -1,6 +1,42 @@ -import { existsSync } from 'node:fs' +import { access } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' + +const AUTH_PRESENCE_TIMEOUT_MS = 5_000 +const authPresenceProbeByPath = new Map>() + +type CodexAuthPresenceOptions = { + signal?: AbortSignal + timeoutMs?: number +} + +function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { + const existing = authPresenceProbeByPath.get(authPath) + if (existing) { + return existing + } + // Why: aborting a Node fs promise does not necessarily cancel an already + // issued UNC operation. Share the raw probe until it really settles so a + // disconnected WSL home cannot accumulate native requests across polls. + const probe = createAuthFilesystemOperation(authPath, () => + access(authPath).then( + () => true, + () => false + ) + ) + authPresenceProbeByPath.set(authPath, probe) + const clearProbe = (): void => { + if (authPresenceProbeByPath.get(authPath) === probe) { + authPresenceProbeByPath.delete(authPath) + } + } + void probe.result.then(clearProbe, clearProbe) + return probe +} // Why: the background quota poller spawns the real `codex` binary to read rate // limits. For users who installed Codex but never signed in, that spawn can @@ -8,12 +44,21 @@ import { join } from 'node:path' // the background. A signed-in Codex always writes an auth.json under its // CODEX_HOME, so gating the fetch on that file keeps the poller silent until // the user actually uses Codex. -export function codexAuthExists(codexHomePath?: string | null): boolean { +export async function codexAuthExists( + codexHomePath?: string | null, + options: CodexAuthPresenceOptions = {} +): Promise { // Mirror Codex's own home resolution: an explicit managed-account home wins, // then CODEX_HOME, then the default ~/.codex. const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex') + const authPath = join(home, 'auth.json') + const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_PRESENCE_TIMEOUT_MS) + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal try { - return existsSync(join(home, 'auth.json')) + // Why: managed WSL homes are UNC paths. A synchronous stat can park + // Electron main while Windows wakes or reconnects the distro; the race + // also keeps a disconnected distro from serializing all later refreshes. + return await getAuthPresenceProbe(authPath).wait(signal) } catch { return false } diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts new file mode 100644 index 00000000000..8a0e2ee7028 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -0,0 +1,194 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) })) + +import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' + +describe('Codex backend rate-limit requests', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uses the official backend usage contract without spawning Codex', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + limit_window_seconds: 3_600, + reset_at: 1_800_000_000 + }, + secondary_window: { + used_percent: 34, + limit_window_seconds: 86_400, + reset_at: 1_800_100_000 + } + }, + rate_limit_reset_credits: { available_count: 1 } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + available_count: 1, + credits: [ + { + status: 'available', + expires_at: '2027-01-15T12:00:00Z', + granted_at: '2027-01-08T12:00:00Z' + } + ] + }) + } as Response) + + await expect( + fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + ).resolves.toMatchObject({ + session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 }, + weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 }, + rateLimitResetCredits: { + availableCount: 1, + nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') + }, + status: 'ok' + }) + + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://chatgpt.com/backend-api/wham/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + signal: expect.any(AbortSignal) + }) + ) + }) + + it('aborts callers while sharing one stalled backend auth read', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + + const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal }) + const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) + await vi.advanceTimersByTimeAsync(0) + expect(readFileMock).toHaveBeenCalledTimes(1) + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') + + firstController.abort() + secondController.abort() + + await expect(first).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + await expect(second).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(fetch).not.toHaveBeenCalled() + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('applies the backend deadline while auth reading is still pending', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal) + const deadlineError = new Error('backend deadline') + + const result = consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/deadline-home', + idempotencyKey: 'redeem-timeout' + }) + await vi.advanceTimersByTimeAsync(0) + // Why: redeem is user-triggered, so it gets the longer redeem deadline. + expect(timeout).toHaveBeenCalledWith(30_000) + expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') + + timeoutController.abort(deadlineError) + + await expect(result).rejects.toBe(deadlineError) + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('consumes a reset credit with the official payload and bounded request signal', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: 'already_redeemed' }) + } as Response) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-123' + }) + ).resolves.toBe('alreadyRedeemed') + + expect(fetch).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ redeem_request_id: 'redeem-123' }), + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + 'ChatGPT-Account-Id': 'account-id', + 'Content-Type': 'application/json' + }) + }) + ) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 77532918702..a2031ac9941 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(codexAuthExists).mockReturnValue(true) + vi.mocked(codexAuthExists).mockResolvedValue(true) readFileMock.mockRejectedValue(new Error('no auth fixture')) vi.stubGlobal('fetch', vi.fn()) }) @@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => { }) it('does not spawn Codex when the user is not signed in', async () => { - vi.mocked(codexAuthExists).mockReturnValue(false) + vi.mocked(codexAuthExists).mockResolvedValue(false) await expect(fetchCodexRateLimits()).resolves.toMatchObject({ provider: 'codex', @@ -101,6 +101,28 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('preserves the aborted result when cancellation lands during the auth check', async () => { + let resolveAuth!: (exists: boolean) => void + vi.mocked(codexAuthExists).mockReturnValueOnce( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + const controller = new AbortController() + + const result = fetchCodexRateLimits({ signal: controller.signal }) + controller.abort() + resolveAuth(false) + + await expect(result).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() @@ -133,6 +155,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string expect(spawnCwd).toContain('rate-limit-pty-cwd') @@ -168,6 +191,7 @@ describe('fetchCodexRateLimits', () => { const controller = new AbortController() const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) controller.abort() @@ -221,6 +245,7 @@ describe('fetchCodexRateLimits', () => { }) const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -247,6 +272,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -421,6 +447,7 @@ describe('fetchCodexRateLimits', () => { expect(fetch).toHaveBeenCalledWith( 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', expect.objectContaining({ + signal: expect.any(AbortSignal), headers: expect.objectContaining({ Authorization: 'Bearer access-token', 'ChatGPT-Account-Id': 'account-id', @@ -553,6 +580,8 @@ describe('fetchCodexRateLimits', () => { ) expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -649,6 +678,7 @@ describe('fetchCodexRateLimits', () => { const resultPromise = fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -668,6 +698,8 @@ describe('fetchCodexRateLimits', () => { "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) expect(shellCommand).toContain('exec codex ') + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') 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 c75be611fe4..8a3ed94bdff 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -25,10 +25,18 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 +const BACKEND_TIMEOUT_MS = 10_000 +// Why: redeeming a reset credit is an explicit user action, not a background +// poll — give it more room before failing so a slow backend can still finish. +const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { @@ -102,6 +110,21 @@ type BackendRateLimitResetCreditsResponse = { }[] } +type BackendRateLimitWindow = { + used_percent?: number + limit_window_seconds?: number + reset_at?: number +} + +type BackendUsageResponse = { + plan_type?: string + rate_limit?: { + primary_window?: BackendRateLimitWindow | null + secondary_window?: BackendRateLimitWindow | null + } | null + rate_limit_reset_credits?: BackendRateLimitResetCreditsResponse | null +} + type BackendConsumeRateLimitResetCreditResponse = { code?: string } @@ -110,6 +133,15 @@ type CodexBackendAuthHeaders = { headers: Record } +type BackendAuthReadResult = + | { content: string; error?: never } + | { content?: never; error: unknown } + +const backendAuthReadByPath = new Map< + string, + SharedAuthFilesystemOperation +>() + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -126,14 +158,19 @@ function buildWslCodexCommand( if (process.platform !== 'win32' || !wslInfo) { return null } - const script = [ + const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}${ - options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' - }` + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}` ].join(' && ') - const loginShellCommand = buildWslLoginShellCommand(script) + const execSuffix = `${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` + // Why: npm/nvm Codex launchers use `#!/usr/bin/env node`. Resolving an + // absolute launcher in a login shell and later execing it from plain `sh` + // loses the PATH that supplies Node and also pins obsolete installations. + const loginShellCommand = buildWslLoginShellCommand( + [setupCommands, `exec codex ${execSuffix}`].join(' && ') + ) // 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 @@ -255,14 +292,57 @@ function mapBackendRateLimitResetCredits( } } +function createBackendRequestSignal( + callerSignal?: AbortSignal, + timeoutMs = BACKEND_TIMEOUT_MS +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs) + return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal +} + +function getBackendAuthRead( + authPath: string +): SharedAuthFilesystemOperation { + const existing = backendAuthReadByPath.get(authPath) + if (existing) { + return existing + } + // Why: the caller deadline must settle promptly, but Node cannot guarantee + // cancellation of an already-issued UNC read. Keep one raw read per auth + // path until the OS finishes so repeated quota refreshes cannot stack them. + const read = createAuthFilesystemOperation(authPath, () => + readFile(authPath, 'utf8').then( + (content) => ({ content }), + (error: unknown) => ({ error }) + ) + ) + backendAuthReadByPath.set(authPath, read) + const clearRead = (): void => { + if (backendAuthReadByPath.get(authPath) === read) { + backendAuthReadByPath.delete(authPath) + } + } + void read.result.then(clearRead, clearRead) + return read +} + +async function readBackendAuth(authPath: string, signal: AbortSignal): Promise { + const result = await getBackendAuthRead(authPath).wait(signal) + if ('error' in result) { + throw result.error + } + return result.content +} + async function getCodexBackendAuthHeaders( - options?: FetchCodexRateLimitsOptions + options: FetchCodexRateLimitsOptions | undefined, + signal: AbortSignal ): Promise { - if (options?.signal?.aborted) { + if (signal.aborted) { return null } const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json') - const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile + const auth = JSON.parse(await readBackendAuth(authPath, signal)) as CodexAuthFile const accessToken = auth.tokens?.access_token if (!accessToken) { return null @@ -286,18 +366,19 @@ async function fetchBackendRateLimitResetCredits( if (options?.signal?.aborted) { return null } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { return null } - if (options?.signal?.aborted) { + if (signal.aborted) { return null } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { ...auth, - signal: options?.signal + signal }) if (!response.ok) { return null @@ -349,7 +430,8 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!options.idempotencyKey.trim()) { throw new Error('Codex reset idempotency key is required') } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { throw new Error('Codex not signed in') } @@ -361,7 +443,8 @@ export async function consumeCodexRateLimitResetCredit(options: { ...auth.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ redeem_request_id: options.idempotencyKey }) + body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), + signal } ) if (!response.ok) { @@ -375,13 +458,13 @@ function mapRpcWindow( raw: RpcRateWindow | undefined, expectedWindowMinutes: number ): RateLimitWindow | null { - if (!raw || typeof raw.usedPercent !== 'number') { + if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) { return null } let resetDescription: string | null = null let resetsAt: number | null = null - if (raw.resetsAt) { + if (typeof raw.resetsAt === 'number' && Number.isFinite(raw.resetsAt) && raw.resetsAt > 0) { // Why: Codex returns resetsAt as Unix seconds, not milliseconds. const date = new Date(raw.resetsAt * 1000) if (!Number.isNaN(date.getTime())) { @@ -408,6 +491,71 @@ function mapRpcWindow( } } +function mapBackendUsageWindow( + raw: BackendRateLimitWindow | null | undefined, + fallbackWindowMinutes: number +): RateLimitWindow | null { + const limitWindowSeconds = raw?.limit_window_seconds + // Match Codex backend-client's `window_minutes_from_seconds`: the backend + // field is the actual bucket duration and rounds partial minutes upward. + const windowMinutes = + typeof limitWindowSeconds === 'number' && + Number.isFinite(limitWindowSeconds) && + limitWindowSeconds > 0 + ? Math.ceil(limitWindowSeconds / 60) + : fallbackWindowMinutes + return mapRpcWindow( + raw + ? { + usedPercent: raw.used_percent, + resetsAt: raw.reset_at + } + : undefined, + windowMinutes + ) +} + +async function fetchViaBackend( + options?: FetchCodexRateLimitsOptions +): Promise { + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) + if (!auth || signal.aborted) { + return null + } + // Why: Codex itself reads this endpoint in backend-client's + // `get_rate_limit_status`; using the same contract avoids launching a hidden + // app-server (and a WSL login shell) for every routine quota refresh. + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + ...auth, + signal + }) + if (!response.ok) { + return null + } + const payload = (await response.json()) as BackendUsageResponse + // `plan_type` is required by Codex's RateLimitStatusPayload. Reject a + // superficially successful but unrelated/malformed JSON response so the + // established app-server fallback still gets a chance. + if (typeof payload.plan_type !== 'string') { + return null + } + return { + provider: 'codex', + session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300), + weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080), + ...(payload.rate_limit_reset_credits !== undefined + ? { + rateLimitResetCredits: + mapBackendRateLimitResetCredits(payload.rate_limit_reset_credits) ?? null + } + : {}), + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + // --------------------------------------------------------------------------- // RPC fetch — spawn `codex -s read-only -a untrusted app-server` // --------------------------------------------------------------------------- @@ -878,7 +1026,13 @@ export async function fetchCodexRateLimits( // Why: never spawn the `codex` binary unless the user has signed in. Without // auth the RPC/PTY paths can only error, and spawning them shows up as an // unexpected background Codex process for users who don't use Codex. - if (!codexAuthExists(options?.codexHomePath)) { + const hasCodexAuth = await codexAuthExists(options?.codexHomePath, { + signal: options?.signal + }) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + if (!hasCodexAuth) { return { provider: 'codex', session: null, @@ -889,7 +1043,29 @@ export async function fetchCodexRateLimits( } } - // Path A: try RPC first + // Path A (WSL): use Codex's own backend usage contract. Host accounts retain + // app-server's token-refresh/custom-CA behavior; WSL avoids starting a login + // shell just to reconstruct the CLI environment for a routine poll. + if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) { + try { + const backendResult = await fetchViaBackend(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + if (backendResult) { + const withResetCredits = await withBackendRateLimitResetCredits(backendResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits + } + } catch { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + // Token refresh, network routing, and custom-CA behavior can differ from + // the host fetch stack. Preserve the CLI paths as compatibility fallbacks. + } + } + + // Path B: try RPC try { const rpcResult = await fetchViaRpc(options) if (options?.signal?.aborted) { @@ -924,7 +1100,7 @@ export async function fetchCodexRateLimits( // RPC failed — fall through to PTY } - // Path B: PTY fallback + // Path C: PTY fallback try { if (options?.signal?.aborted) { return abortedCodexRateLimitResult() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index eac3b6c6411..f72d94c4f9f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14898,7 +14898,24 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + }) } as never) runtime.setNotifier({ worktreesChanged: vi.fn(), @@ -14915,13 +14932,13 @@ describe('OrcaRuntimeService', () => { 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). + // A renderer must be attached to receive the wake — headless serve reports + // the explicit 'unsupported-headless' outcome instead (see below). 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}`, { + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false, clientKind: 'mobile' }) @@ -14930,6 +14947,86 @@ describe('OrcaRuntimeService', () => { // routes exclusively through the renderer's own navigation-free wake. expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) expect(activateWorktree).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('requested') + }) + + it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => { + // Why: without an attached renderer nothing holds the sleeping records, so + // no agent actually wakes. The result must say so explicitly — activating + // silently would let the phone present slept agents as resumed (#7906). + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const getWorkspaceSession = vi.fn(() => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + })) + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession + } 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() + }) + // Headless/no-live-renderer: the graph is ready but no BrowserWindow backs + // the authoritative id, so getAvailableAuthoritativeWindow() is null. + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.activated).toBe(true) + expect(result.sleepingAgentWake).toBe('unsupported-headless') + // Why: sleeping records are host-partitioned; the check must read the + // repo's execution host partition, not always the local one. + expect(getWorkspaceSession).toHaveBeenCalledWith('local') + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + + it('does not report headless wake degradation without sleeping records', async () => { + const runtime = new OrcaRuntimeService(store as never) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.sleepingAgentWake).toBe('not-applicable') }) it('does not wake slept agents for non-mobile session-only activation', async () => { @@ -14963,12 +15060,13 @@ describe('OrcaRuntimeService', () => { // 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}`, { + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false, clientKind: 'runtime' }) expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('not-applicable') }) it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 546d40a5730..43ea05133bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -127,7 +127,11 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' @@ -12622,6 +12626,10 @@ export class OrcaRuntimeService { repoId: string worktreeId: string activated: boolean + /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no + * renderer holds the sleeping records (headless `orca serve`), so nothing + * woke — clients must not present the worktree's agents as resumed. */ + sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { this.assertGraphReady() const worktree = await this.resolveWorktreeSelector(worktreeSelector) @@ -12637,6 +12645,8 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) } + let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = + 'not-applicable' if (opts.notifyClients !== false) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. @@ -12652,12 +12662,29 @@ export class OrcaRuntimeService { // 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) + // unaffected. Headless serve has no renderer to wake anything, so report + // that explicitly instead of letting mobile assume the agents resumed. + if (opts.clientKind === 'mobile') { + if (this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + sleepingAgentWake = 'requested' + } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. + Object.values( + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} + ).some((record) => record.worktreeId === worktree.id) + ) { + // Why: headless is only degraded when this worktree actually has a + // persisted resume record. Ordinary mobile activation must not show + // an unsupported warning merely because no desktop window is open. + sleepingAgentWake = 'unsupported-headless' + } } } - return { repoId: repo.id, worktreeId: worktree.id, activated: true } + return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } private async buildStartupForDraft( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eb34e42b62e..ce443c55454 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, type SetStateAction } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' @@ -170,6 +171,10 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import { + hasRequestedBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests +} from './components/terminal/background-terminal-worktree-mount' const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') @@ -481,6 +486,11 @@ function App(): React.JSX.Element { const worktreeSidebarScrollAnchorRef = useRef(null) const floatingVisibleTabCount = useAppStore(selectFloatingVisibleTabCount) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const backgroundTerminalMountRequested = useSyncExternalStore( + subscribeBackgroundTerminalWorktreeMountRequests, + hasRequestedBackgroundTerminalWorktreeMount, + hasRequestedBackgroundTerminalWorktreeMount + ) const keybindings = useAppStore((s) => s.keybindings) const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) @@ -497,14 +507,16 @@ function App(): React.JSX.Element { floatingTerminalEnabled && (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) const hasMountedTerminalWorkbenchRef = useRef(false) - if (activeWorktreeId !== null) { + if (activeWorktreeId !== null || backgroundTerminalMountRequested) { hasMountedTerminalWorkbenchRef.current = true } // Why: skip the terminal bundle on the no-workspace landing path, but once a // workspace has mounted, keep Terminal-owned hidden panes alive through sleep // and shutdown transitions where activeWorktreeId can briefly become null. const shouldMountTerminalWorkbench = - activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current + activeWorktreeId !== null || + backgroundTerminalMountRequested || + hasMountedTerminalWorkbenchRef.current // Why: visible worktree creation owns its faux tab strip from start to finish; // the previous workspace must stay mounted for retention without rendering // real chrome. diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 6109a53d890..119ac5f77e0 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -166,7 +166,9 @@ describe('renderer startup runtime routing', () => { expect(source).not.toContain("from './components/Terminal'") expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)') expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true') - expect(source).toContain('activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current') + expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested') + expect(source).toContain('backgroundTerminalMountRequested ||') + expect(source).toContain('hasMountedTerminalWorkbenchRef.current') expect(source).toContain('shouldMountTerminalWorkbench ?') }) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e2d4b5892ea..c4d331697c2 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -59,6 +59,13 @@ import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' +import { + applyBackgroundMountTabRestriction, + pruneClosedBackgroundMountTabs, + shouldMountBackgroundWorktreeTab, + takeAllPendingBackgroundTerminalWorktreeMounts, + takePendingBackgroundTerminalWorktreeMount +} from './terminal/background-terminal-worktree-mount' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout @@ -730,13 +737,22 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + // Why: background-mounted worktrees restricted to specific tabs (targeted + // wake/resume) must not instantiate a TerminalPane per saved tab. A worktree + // absent from this map mounts all of its tabs. + const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const [, setBackgroundMountRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current - const onBackgroundMountTerminalWorktree = (event: Event): void => { - const customEvent = event as CustomEvent - const worktreeId = customEvent.detail?.worktreeId + const applyBackgroundMount = (detail: BackgroundMountTerminalWorktreeDetail): void => { + const worktreeId = detail.worktreeId + applyBackgroundMountTabRestriction( + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + worktreeId, + detail.tabIds + ) scheduleBackgroundTerminalWorktreeMeasure({ mountedWorktreeIds: mountedWorktreeIdsRef.current, measurableBackgroundWorktreeIds: measurableBackgroundWorktreeIdsRef.current, @@ -747,10 +763,24 @@ function Terminal(): React.JSX.Element | null { clearTimeoutFn: window.clearTimeout }) } + const onBackgroundMountTerminalWorktree = (event: Event): void => { + const customEvent = event as CustomEvent + const worktreeId = customEvent.detail?.worktreeId + const pending = takePendingBackgroundTerminalWorktreeMount(worktreeId) + const detail = pending ?? customEvent.detail + if (detail?.worktreeId) { + applyBackgroundMount(detail) + } + } window.addEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onBackgroundMountTerminalWorktree as EventListener ) + // Requests made while the lazy Terminal bundle/effect was absent stay in + // the registry and are replayed only after the listener owns the surface. + for (const pending of takeAllPendingBackgroundTerminalWorktreeMounts()) { + applyBackgroundMount(pending) + } return () => { window.removeEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, @@ -775,12 +805,21 @@ function Terminal(): React.JSX.Element | null { // creating a duplicate PTY for the same tab. if (renderedActiveWorktreeId && workspaceSessionReady) { mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) + // Why: a real activation supersedes any targeted background mount — the + // visible worktree needs all of its tabs. + backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) } + pruneClosedBackgroundMountTabs( + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + tabsByWorktree + ) // Prune IDs of worktrees that no longer exist (deleted/removed) const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const id of mountedWorktreeIdsRef.current) { if (!allWorktreeIds.has(id)) { mountedWorktreeIdsRef.current.delete(id) + backgroundMountTabIdsByWorktreeRef.current.delete(id) } } const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout( @@ -1830,6 +1869,9 @@ function Terminal(): React.JSX.Element | null { isVisible={isVisible} shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} + backgroundMountTabIds={ + backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + } /> ) })} @@ -1893,47 +1935,56 @@ function Terminal(): React.JSX.Element | null { aria-hidden={!isVisible} > - {(tabsByWorktree[workspace.id] ?? []).map((tab) => { - const activityTerminalPortal = findActivityTerminalPortal( - activityTerminalPortals, - { worktreeId: workspace.id, tabId: tab.id } - ) - const isActivityPortalTab = activityTerminalPortal !== null - const isActiveTerminalTab = - isVisible && tab.id === activeTabId && activeTabType === 'terminal' - const terminalPane = ( - handlePtyExit(tab.id, ptyId)} - onCloseTab={() => handleCloseTab(tab.id)} - /> - ) - if (activityTerminalPortal) { - return createPortal( - terminalPane, - activityTerminalPortal.target, - `activity-terminal-${tab.id}` + {(tabsByWorktree[workspace.id] ?? []) + .filter((tab) => + shouldMountBackgroundWorktreeTab( + backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null, + tab.id ) - } - return terminalPane - })} + ) + .map((tab) => { + const activityTerminalPortal = findActivityTerminalPortal( + activityTerminalPortals, + { worktreeId: workspace.id, tabId: tab.id } + ) + const isActivityPortalTab = activityTerminalPortal !== null + const isActiveTerminalTab = + isVisible && tab.id === activeTabId && activeTabType === 'terminal' + const terminalPane = ( + handlePtyExit(tab.id, ptyId)} + onCloseTab={() => handleCloseTab(tab.id)} + /> + ) + if (activityTerminalPortal) { + return createPortal( + terminalPane, + activityTerminalPortal.target, + `activity-terminal-${tab.id}` + ) + } + return terminalPane + })}
) })} @@ -2107,7 +2158,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId, isVisible, shouldMeasureHiddenWorktree, - activityTerminalPortals + activityTerminalPortals, + backgroundMountTabIds }: { worktreeId: string worktreePath: string @@ -2116,6 +2168,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ isVisible: boolean shouldMeasureHiddenWorktree: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] + backgroundMountTabIds: ReadonlySet | null }): React.JSX.Element { const browserPageIds = useAppStore( useShallow((state) => @@ -2155,9 +2208,14 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreePath={worktreePath} isWorktreeActive={isVisible} activityTerminalPortals={activityTerminalPortals} + backgroundMountTabIds={backgroundMountTabIds} /> - - + {isVisible || backgroundMountTabIds === null ? ( + <> + + + + ) : null}
) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0e68af9126f..8e1c8f17d8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -11,6 +11,7 @@ import { } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' +import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' type TerminalOverlayAssignment = { @@ -278,12 +279,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, - activityTerminalPortals = EMPTY_ACTIVITY_PORTALS + activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, + backgroundMountTabIds = null }: { worktreeId: string worktreePath: string isWorktreeActive: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] + /** Non-null for targeted background mounts: only these terminal tabs get a + * TerminalPane, so waking one slept agent does not connect every saved tab. */ + backgroundMountTabIds?: ReadonlySet | null }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( useShallow((state) => ({ @@ -352,34 +357,38 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return ( <> - {terminalTabs.map((terminalTab) => { - const assignment = assignments.get(terminalTab.id) - const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) - const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) - const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId, - tabId: terminalTab.id - }) - return ( - + {terminalTabs + .filter((terminalTab) => + shouldMountBackgroundWorktreeTab(backgroundMountTabIds, terminalTab.id) ) - })} + .map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) + return ( + + ) + })} ) }) 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 63a28b7819a..9368f810cbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -174,6 +174,7 @@ type StoreState = { agentStatusByPaneKey: Record paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record + suppressedPtyExitIds: Record agentLaunchConfigByPaneKey: Record getAgentLaunchConfigForStatusEntry: ReturnType getAgentLaunchConfigForStatusMetadata: ReturnType @@ -748,6 +749,7 @@ describe('connectPanePty', () => { agentStatusByPaneKey: {}, paneForegroundAgentByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, + suppressedPtyExitIds: {}, agentLaunchConfigByPaneKey: {}, getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig @@ -1830,6 +1832,7 @@ describe('connectPanePty', () => { updatedAt: 1, origin: 'worktree-sleep' } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { noteVisibilityResume: () => void @@ -1898,9 +1901,10 @@ describe('connectPanePty', () => { updatedAt: 1, origin: 'worktree-sleep' } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - wakeHibernatedAgentIfArmed: () => void + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null dispose: () => void } await flushAsyncTicks() @@ -1913,7 +1917,14 @@ describe('connectPanePty', () => { // Still hidden: no reveal happened, so nothing respawned on exit. expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - binding.wakeHibernatedAgentIfArmed() + // The wake reports the claim it consumed so the dispatcher's generic + // resume never launches the same provider session into a second tab. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-bg' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) await flushAsyncTicks() expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) @@ -1930,6 +1941,183 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) }) + it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => { + // Race (#7906): mobile opens the worktree after the sleeping record is + // written but before the suppressed kill's exit sets hibernatedWakePtyId. + // The wake is edge-triggered and the phone never reveals the desktop pane, + // so without a latch the wake would be dropped and the phone left staring + // at a frozen terminal. + 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-race' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + dispose: () => void + } + await flushAsyncTicks() + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + + // Wake arrives mid-kill: nothing is armed yet, but the pane must claim the + // session (suppressing the generic resume) and latch the request. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-race' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) + const connectCallsBeforeExit = transport.connect.mock.calls.length + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // Arming consumed the latched wake — the --resume spawned with no reveal + // and no second wake event. + 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-race') + }) + + it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + 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-inflight' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + deps as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + const deferredSpawn = createDeferred() + transport.connect.mockImplementationOnce(() => deferredSpawn.promise) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + const connectCallsAfterFirstWake = transport.connect.mock.calls.length + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFirstWake) + + deferredSpawn.resolve('pty-resumed') + await flushAsyncTicks() + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('re-arms the exact hibernation target after a replacement spawn fails', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + 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-retry' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) as never + ) as unknown as { wakeHibernatedAgentIfArmed: () => string | null } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + await flushAsyncTicks(20) + const connectCallsAfterFailure = transport.connect.mock.calls.length + + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFailure + 1) + }) + + it('does not latch a stale sleeping record beside an unsuppressed live PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-stale' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + expect(binding.wakeHibernatedAgentIfArmed()).toBeNull() + }) + 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 df753a47114..e86afd8018e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -88,6 +88,10 @@ import { } from '@/lib/pane-manager/terminal-scroll-intent' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence +} from '@/lib/sleeping-agent-pane-ownership' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -676,8 +680,10 @@ type PanePtyBinding = IDisposable & { /** 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 + * desktop hidden→visible transition. Returns the sleeping record's provider + * session claim key when this pane started (or latched) the in-place wake, + * so the follow-up generic resume never launches the same session twice. */ + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null /** 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. */ @@ -2079,35 +2085,80 @@ export function connectPanePty( let spawnedFreshPtyId: string | null = null // Why: hibernation suppresses its kill's exit while the pane is hidden, so // onExit must not tear the pane down — but the pane still owes the user a - // wake. Remember the hibernated ptyId; the visibility-resume hook consumes - // it and relaunches the recorded agent session on next reveal. - let hibernatedWakePtyId: string | null = null - let wakeHibernatedAgentPane: (() => void) | null = null + // wake. Remember the hibernated PTY and exact record; the visibility-resume + // hook consumes both and cannot accidentally adopt a later stale record. + type HibernatedWakeTarget = { ptyId: string; record: SleepingAgentSessionRecord } + let hibernatedWakeTarget: HibernatedWakeTarget | null = null + let wakeHibernatedAgentPane: (() => Promise) | null = null + // Why: a mobile wake can land after the sleeping record is written but + // before the suppressed kill exit arms the wake target. The phone never + // reveals the desktop pane, so without a latch the edge-triggered wake would + // be dropped and the phone left on a frozen terminal. + let pendingHibernatedWakeTarget: HibernatedWakeTarget | null = null + // Why: transport.connect settles asynchronously. Repeated mobile activation + // must keep claiming this provider session until the replacement PTY either + // exists (and clears the sleep record) or the spawn fails and can be retried. + let hibernatedWakeInFlightClaimKey: string | null = null // Why: reveal is the normal wake trigger, but a reveal that lands *during* the // in-flight hibernation kill runs noteVisibilityResume before onExit arms the // wake. Sharing the guarded consume lets both the reveal hook and the // arm-time foreground check resume the pane exactly once. - const consumeHibernatedAgentWake = (): void => { - if (hibernatedWakePtyId === null || disposed) { - return + const consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => { + const target = hibernatedWakeTarget + if (!target || disposed) { + return null } if (deps.paneTransportsRef.current.get(pane.id) !== transport) { - return + return null + } + const currentRecord = getSleepingRecordForPane(useAppStore.getState())?.record + if (currentRecord !== target.record) { + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + return null } const currentPtyId = transport.getPtyId() // Why: a real pty:exit clears the transport's ptyId before onExit while a // reconcile-driven exit leaves it bound; both mean "nothing respawned since // hibernation". A different non-null id means another flow (e.g. an // intentional restart) already rebound the pane — its spawn wins. - if (currentPtyId !== null && currentPtyId !== hibernatedWakePtyId) { - hibernatedWakePtyId = null - return + if (currentPtyId !== null && currentPtyId !== target.ptyId) { + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + return null } - hibernatedWakePtyId = null + if (!wakeHibernatedAgentPane) { + return null + } + const claimKey = getProviderSessionClaimKey(target.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + // Why: one wake event can visit multiple mounted legacy/stable panes for + // the same provider session. Claim synchronously before any spawn starts. + claimedProviderSessions?.add(claimKey) + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + hibernatedWakeInFlightClaimKey = claimKey // Why: reveal is the wake signal for a hibernated pane. Resume the recorded // agent session (or fall back to a fresh shell) instead of leaving the // frozen frame with no PTY behind it. - wakeHibernatedAgentPane?.() + void wakeHibernatedAgentPane() + .then((spawnedPtyId) => { + if (!spawnedPtyId) { + // Why: a transient replacement-spawn failure leaves the passive + // record owned by this pane. Re-arm the exact target so a later + // mobile open can retry instead of stranding the frozen session; + // consume revalidates disposal, binding, PTY, and record identity. + hibernatedWakeTarget = target + } + }) + .finally(() => { + if (hibernatedWakeInFlightClaimKey === claimKey) { + hibernatedWakeInFlightClaimKey = null + } + }) + return claimKey } const onExit = (ptyId: string): void => { if (handledExitPtyId === ptyId) { @@ -2153,20 +2204,34 @@ export function connectPanePty( // Why: the action that suppressed the exit owns whether the leaf binding // is a wake hint or should be discarded; runtime cleanup above is enough. manager.setPaneGpuRendering(pane.id, true) - if (getSleepingRecordForPane(useAppStore.getState())) { + const sleepingRecordEntry = getSleepingRecordForPane(useAppStore.getState()) + if ( + sleepingRecordEntry && + isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record) + ) { // Why: hibernation killed this pane's PTY while hidden. The frozen TUI // frame still has mouse-tracking/bracketed-paste armed, which silently // eats every click and keystroke against a dead transport — disarm the // modes now and arm the reveal-time wake. replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET) - hibernatedWakePtyId = ptyId - if (deps.isVisibleRef.current) { - // Why: a reveal that raced this kill already ran noteVisibilityResume - // before the exit landed, so it saw nothing armed. Consume the wake - // now (deferred off the exit handler) so a foreground pane still - // resumes without needing a second hide/reveal. - queueMicrotask(consumeHibernatedAgentWake) + hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } + const pendingWakeMatches = + pendingHibernatedWakeTarget?.ptyId === ptyId && + pendingHibernatedWakeTarget.record === sleepingRecordEntry.record + if (pendingHibernatedWakeTarget && !pendingWakeMatches) { + pendingHibernatedWakeTarget = null } + if (deps.isVisibleRef.current || pendingWakeMatches) { + // Why: a reveal (or a mobile wake) that raced this kill already ran + // before the exit landed, so it saw nothing armed. Consume the wake + // now (deferred off the exit handler) so the pane still resumes + // without needing a second hide/reveal or wake event. + queueMicrotask(() => { + consumeHibernatedAgentWake() + }) + } + } else if (pendingHibernatedWakeTarget?.ptyId === ptyId) { + pendingHibernatedWakeTarget = null } return } @@ -3669,9 +3734,9 @@ export function connectPanePty( const startFreshColdRestoreAgentResume = ( startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} - ): void => { + ): Promise => { applyColdRestoreAgentResumeStartup(startup) - startFreshSpawn(startup, options) + return startFreshSpawn(startup, options) } // Why: the hibernation wake fires from noteVisibilityResume in the outer // connection scope, long after this deferred-connect closure has run. @@ -3751,7 +3816,7 @@ export function connectPanePty( const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} - ): void => { + ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() prepareFreshShellViewportForSpawn(options) @@ -3880,6 +3945,7 @@ export function connectPanePty( // Why: split panes in the same tab can spawn concurrently. Key by pane // as well as tab so a remount cannot attach to a sibling setup pane's PTY. pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) + return trackedPromise } let foregroundRefreshRiskScanTail = '' @@ -6251,8 +6317,44 @@ export function connectPanePty( }, // 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() + wakeHibernatedAgentIfArmed(claimedProviderSessions) { + if (hibernatedWakeInFlightClaimKey) { + if (claimedProviderSessions?.has(hibernatedWakeInFlightClaimKey)) { + return null + } + claimedProviderSessions?.add(hibernatedWakeInFlightClaimKey) + return hibernatedWakeInFlightClaimKey + } + const consumedClaimKey = consumeHibernatedAgentWake(claimedProviderSessions) + if (consumedClaimKey) { + return consumedClaimKey + } + // Why: wake arrived mid-hibernation-kill — the record exists but onExit + // has not armed the wake target yet (the transport is still bound to the + // dying PTY). Only the exact PTY already marked for suppressed shutdown + // may latch; a stale/manual record beside an ordinary live PTY must not. + const state = useAppStore.getState() + const recordEntry = getSleepingRecordForPane(state) + const currentPtyId = transport.getPtyId() + if ( + recordEntry && + isPassiveCompletedHibernationEvidence(recordEntry.record) && + currentPtyId !== null && + state.suppressedPtyExitIds[currentPtyId] === true && + !disposed && + hibernatedWakeTarget === null && + deps.paneTransportsRef.current.get(pane.id) === transport && + transport.getPtyId() === currentPtyId + ) { + const claimKey = getProviderSessionClaimKey(recordEntry.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + claimedProviderSessions?.add(claimKey) + pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record } + return claimKey + } + return null }, 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 4f9d93ed934..a91514856f1 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 @@ -1760,9 +1760,18 @@ export function useTerminalPaneLifecycle({ return } for (const panePtyBinding of panePtyBindingsRef.current.values()) { - ;( - panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void } - ).wakeHibernatedAgentIfArmed?.() + const claimKey = ( + panePtyBinding as IDisposable & { + wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null + } + ).wakeHibernatedAgentIfArmed?.(detail.wokenClaimKeys) + // Why: the dispatcher's follow-up generic resume must skip provider + // sessions this pane woke (or latched) in place — the sleeping record + // is only cleared after the in-place spawn succeeds, so without this + // the same session would resume twice. + if (claimKey) { + detail.wokenClaimKeys?.add(claimKey) + } } } window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index f2cef1eae04..9292eede164 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -1,6 +1,48 @@ import { describe, expect, it, vi } from 'vitest' -import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount' +import { + addBackgroundMountedTerminalWorktree, + applyBackgroundMountTabRestriction, + hasRequestedBackgroundTerminalWorktreeMount, + pruneClosedBackgroundMountTabs, + requestBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests, + takeAllPendingBackgroundTerminalWorktreeMounts, + shouldMountBackgroundWorktreeTab +} from './background-terminal-worktree-mount' + +describe('background terminal mount request registry', () => { + it('replays a request made before the Terminal listener mounts', () => { + takeAllPendingBackgroundTerminalWorktreeMounts() + const onRequest = vi.fn() + const unsubscribe = subscribeBackgroundTerminalWorktreeMountRequests(onRequest) + + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-cold', tabIds: ['tab-1'] }) + + expect(hasRequestedBackgroundTerminalWorktreeMount()).toBe(true) + expect(onRequest).toHaveBeenCalledOnce() + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { worktreeId: 'wt-cold', tabIds: ['tab-1'] } + ]) + onRequest.mockClear() + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-later', tabIds: ['tab-2'] }) + expect(onRequest).not.toHaveBeenCalled() + unsubscribe() + }) + + it('unions targeted requests and lets a whole-worktree request dominate', () => { + takeAllPendingBackgroundTerminalWorktreeMounts() + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] }) + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-2'] }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { worktreeId: 'wt-1', tabIds: ['tab-1', 'tab-2'] } + ]) + + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] }) + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1' }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([{ worktreeId: 'wt-1' }]) + }) +}) describe('addBackgroundMountedTerminalWorktree', () => { it('adds a hidden worktree mount and notifies the caller once', () => { @@ -24,3 +66,79 @@ describe('addBackgroundMountedTerminalWorktree', () => { expect(onAdded).not.toHaveBeenCalled() }) }) + +describe('applyBackgroundMountTabRestriction', () => { + it('restricts a not-yet-mounted worktree to the targeted tabs and unions later targets', () => { + const restrictions = new Map>() + const mounted = new Set() + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-1']) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1'])) + + mounted.add('wt-1') + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2', 'tab-1']) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2'])) + }) + + it('never narrows a fully mounted worktree retroactively', () => { + const restrictions = new Map>() + const mounted = new Set(['wt-visited']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-visited', ['tab-1']) + expect(restrictions.has('wt-visited')).toBe(false) + }) + + it('lifts the restriction on an untargeted (whole-worktree) mount', () => { + const restrictions = new Map>([['wt-1', new Set(['tab-1'])]]) + const mounted = new Set(['wt-1']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', undefined) + expect(restrictions.has('wt-1')).toBe(false) + }) + + it('keeps the existing set identity when the targets are already covered', () => { + const existing = new Set(['tab-1', 'tab-2']) + const restrictions = new Map>([['wt-1', existing]]) + const mounted = new Set(['wt-1']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2']) + expect(restrictions.get('wt-1')).toBe(existing) + }) +}) + +describe('shouldMountBackgroundWorktreeTab', () => { + it('mounts every tab without a restriction and only listed tabs with one', () => { + expect(shouldMountBackgroundWorktreeTab(null, 'tab-1')).toBe(true) + expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-1')).toBe(true) + expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-2')).toBe(false) + }) +}) + +describe('pruneClosedBackgroundMountTabs', () => { + it('retains live targets and releases the mount after the final target closes', () => { + const restrictions = new Map>([ + ['wt-1', new Set(['tab-1', 'tab-2'])] + ]) + const mounted = new Set(['wt-1']) + + expect( + pruneClosedBackgroundMountTabs(restrictions, mounted, { + 'wt-1': [{ id: 'tab-2' }, { id: 'unrelated-tab' }] + }) + ).toBe(true) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-2'])) + expect(mounted.has('wt-1')).toBe(true) + + expect(pruneClosedBackgroundMountTabs(restrictions, mounted, { 'wt-1': [] })).toBe(true) + expect(restrictions.has('wt-1')).toBe(false) + expect(mounted.has('wt-1')).toBe(false) + }) + + it('leaves unrestricted user-visited and whole-worktree mounts alone', () => { + const restrictions = new Map>() + const mounted = new Set(['wt-visited', 'wt-whole']) + + expect(pruneClosedBackgroundMountTabs(restrictions, mounted, {})).toBe(false) + expect(mounted).toEqual(new Set(['wt-visited', 'wt-whole'])) + }) +}) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index 3aff5faf530..af8bc370d8e 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -1,3 +1,85 @@ +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail +} from '@/constants/terminal' + +const pendingMounts = new Map() +const requestListeners = new Set<() => void>() +let hasRequestedMount = false + +function mergePendingMount(detail: BackgroundMountTerminalWorktreeDetail): void { + const existing = pendingMounts.get(detail.worktreeId) + if (!existing) { + pendingMounts.set(detail.worktreeId, { + worktreeId: detail.worktreeId, + ...(detail.tabIds !== undefined ? { tabIds: [...new Set(detail.tabIds)] } : {}) + }) + return + } + if (existing.tabIds === undefined || detail.tabIds === undefined) { + pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId }) + return + } + pendingMounts.set(detail.worktreeId, { + worktreeId: detail.worktreeId, + tabIds: [...new Set([...existing.tabIds, ...detail.tabIds])] + }) +} + +/** + * Records the request before emitting the compatibility event. Why: Terminal + * is lazy and absent on the landing screen, so a window-only event can fire + * before its listener exists and permanently lose a navigation-free wake. + */ +export function requestBackgroundTerminalWorktreeMount( + detail: BackgroundMountTerminalWorktreeDetail +): void { + if (!detail.worktreeId) { + return + } + mergePendingMount(detail) + if (!hasRequestedMount) { + hasRequestedMount = true + for (const listener of requestListeners) { + listener() + } + } + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent( + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + { detail } + ) + ) + } +} + +export function takePendingBackgroundTerminalWorktreeMount( + worktreeId: string | undefined +): BackgroundMountTerminalWorktreeDetail | null { + if (!worktreeId) { + return null + } + const pending = pendingMounts.get(worktreeId) ?? null + pendingMounts.delete(worktreeId) + return pending +} + +export function takeAllPendingBackgroundTerminalWorktreeMounts(): BackgroundMountTerminalWorktreeDetail[] { + const pending = [...pendingMounts.values()] + pendingMounts.clear() + return pending +} + +export function subscribeBackgroundTerminalWorktreeMountRequests(listener: () => void): () => void { + requestListeners.add(listener) + return () => requestListeners.delete(listener) +} + +export function hasRequestedBackgroundTerminalWorktreeMount(): boolean { + return hasRequestedMount +} + export function addBackgroundMountedTerminalWorktree( mountedWorktreeIds: Set, worktreeId: string | undefined, @@ -10,3 +92,69 @@ export function addBackgroundMountedTerminalWorktree( onAdded() return true } + +/** + * Records which terminal tabs a background mount may instantiate. Why: a + * whole-worktree background mount creates a TerminalPane (xterm + PTY connect) + * for every saved tab, so wake/resume flows pass the exact tabs they need. + * Must run before the worktree is added to `mountedWorktreeIds`. + */ +export function applyBackgroundMountTabRestriction( + restrictions: Map>, + mountedWorktreeIds: ReadonlySet, + worktreeId: string | undefined, + tabIds: readonly string[] | undefined +): void { + if (!worktreeId) { + return + } + const existing = restrictions.get(worktreeId) + // Why: a worktree mounted without a restriction is fully mounted (the user + // visited it, or a legacy whole-worktree mount ran); narrowing it + // retroactively would unmount live panes. + if (mountedWorktreeIds.has(worktreeId) && !existing) { + return + } + if (!tabIds) { + restrictions.delete(worktreeId) + return + } + if (existing && tabIds.every((tabId) => existing.has(tabId))) { + return + } + restrictions.set(worktreeId, new Set([...(existing ?? []), ...tabIds])) +} + +export function shouldMountBackgroundWorktreeTab( + restrictedTabIds: ReadonlySet | null, + tabId: string +): boolean { + return restrictedTabIds === null || restrictedTabIds.has(tabId) +} + +/** + * Releases targeted mounts after their owning tabs close. Whole-worktree and + * user-visited mounts have no restriction entry and are intentionally retained. + */ +export function pruneClosedBackgroundMountTabs( + restrictions: Map>, + mountedWorktreeIds: Set, + tabsByWorktree: Record +): boolean { + let changed = false + for (const [worktreeId, tabIds] of restrictions) { + const liveTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const retained = new Set([...tabIds].filter((tabId) => liveTabIds.has(tabId))) + if (retained.size === tabIds.size) { + continue + } + changed = true + if (retained.size === 0) { + restrictions.delete(worktreeId) + mountedWorktreeIds.delete(worktreeId) + } else { + restrictions.set(worktreeId, retained) + } + } + return changed +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index ed1a89aa634..1c0b5a5b713 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -74,8 +74,19 @@ export type CloseTerminalPaneDetail = { export type BackgroundMountTerminalWorktreeDetail = { worktreeId: string + /** When set, only these terminal tabs mount for the background worktree. + * Why: a worktree-wide background mount instantiates a TerminalPane (and + * its PTY connect work) for every saved tab; wake/resume flows know exactly + * which tabs they need. Omitted → whole-worktree mount (legacy dispatch + * sites); a real activation always lifts the restriction. */ + tabIds?: readonly string[] } export type WakeHibernatedAgentsWorktreeDetail = { worktreeId: string + /** Mutable collector: mounted panes that consume (or latch) the in-place + * hibernation wake add their provider-session claim keys here so the + * dispatcher's follow-up generic resume skips those sessions instead of + * launching them a second time. */ + wokenClaimKeys?: Set } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3a7683073cc..1cebfdce6d6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2125,7 +2125,7 @@ describe('useIpcEvents updater integration', () => { expect(dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'orca-background-mount-terminal-worktree', - detail: { worktreeId: 'wt-3' } + detail: { worktreeId: 'wt-3', tabIds: ['tab-new'] } }) ) expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Shell', { @@ -2188,7 +2188,7 @@ describe('useIpcEvents updater integration', () => { expect(dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'orca-background-mount-terminal-worktree', - detail: { worktreeId: 'wt-2' } + detail: { worktreeId: 'wt-2', tabIds: ['tab-new'] } }) ) expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5657d69a558..debbe00a545 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -9,13 +9,10 @@ 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 { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' -import { - BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - SPLIT_TERMINAL_PANE_EVENT, - CLOSE_TERMINAL_PANE_EVENT -} from '@/constants/terminal' +import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -207,11 +204,7 @@ function acquireBrowserAutomationBootstrapLease( if (!targetWorktreeId) { return } - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId: targetWorktreeId } - }) - ) + requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) let targetBrowserPageId = browserPageId ?? null if (!targetBrowserPageId) { const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] @@ -836,6 +829,8 @@ function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() + unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) type PendingAgentStatusEvent = { data: AgentStatusIpcPayload firstSeenAt: number @@ -1661,14 +1656,6 @@ export function useIpcEvents(): void { const shouldSurfaceOwner = terminalPresentation !== 'background' if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) - } else { - // Why: renderer-backed Codex startup must mount a TerminalPane so the - // PTY is born in the renderer, but it must not switch the active UI. - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId } - }) - ) } const tabOptions = data.launchAgent ? { @@ -1689,6 +1676,11 @@ export function useIpcEvents(): void { ...(data.cwd ? { startupCwd: data.cwd } : {}) } const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) + if (!shouldActivate) { + // Why: renderer-backed Codex startup must mount its new TerminalPane + // without switching UI or connecting every saved tab in the worktree. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } if (data.afterTabId) { const createdUnifiedTab = useAppStore .getState() @@ -1950,7 +1942,7 @@ export function useIpcEvents(): void { 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) + backgroundSleepingAgentWakeDispatcher.request(worktreeId) }) ) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 954a1314ef9..2a089f8411c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { resetRemoteRuntimeTerminalMultiplexersForTests } from '@/runtime/remote-runtime-terminal-multiplexer' const mockSpawn = vi.fn() +const mockKill = vi.fn() const mockWrite = vi.fn() const mockRuntimeEnvironmentCall = vi.fn() const mockRuntimeEnvironmentTransportCall = vi.fn() @@ -99,6 +101,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + resetRemoteRuntimeTerminalMultiplexersForTests() clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() mockRuntimeEnvironmentTransportCall.mockImplementation( @@ -143,7 +146,8 @@ describe('launchAgentBackgroundSession', () => { api: { pty: { spawn: mockSpawn, - write: mockWrite + write: mockWrite, + kill: mockKill }, agentTrust: { markTrusted: mockMarkTrusted @@ -176,9 +180,12 @@ describe('launchAgentBackgroundSession', () => { expect(mockDispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - detail: { worktreeId: 'wt-1' } + detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) ) + expect(mockUpdateTabPtyId.mock.invocationCallOrder[0]).toBeLessThan( + mockDispatchEvent.mock.invocationCallOrder[0] ?? 0 + ) expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', @@ -225,6 +232,33 @@ describe('launchAgentBackgroundSession', () => { expect(result).toMatchObject({ tabId: 'tab-1', paneKey, ptyId: 'pty-1' }) }) + it('does not mount the tab while the explicit PTY spawn is unresolved', async () => { + let resolveSpawn!: (result: { id: string }) => void + mockSpawn.mockReturnValueOnce( + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run slowly' + }) + await Promise.resolve() + + expect(mockCreateTab).toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + + resolveSpawn({ id: 'pty-slow' }) + await expect(launch).resolves.toMatchObject({ ptyId: 'pty-slow' }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-slow') + expect(mockDispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) + ) + }) + it('records effective launch config returned by local PTY spawn', async () => { const effectiveLaunchConfig = { agentCommand: "claude '--dangerously-skip-permissions'", @@ -627,4 +661,29 @@ describe('launchAgentBackgroundSession', () => { ptyId: 'remote:env-1@@terminal-1' }) }) + + it('closes a created runtime terminal when its data subscription fails', async () => { + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscription failed') + + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-1' }, + timeoutMs: undefined + }) + expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 389c6972cf1..85a8f1171d7 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -10,7 +10,7 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -20,7 +20,8 @@ import { repoIsRemote } from '../../../shared/agent-launch-remote' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, - subscribeToPtyExit + subscribeToPtyExit, + type EagerPtyHandle } from '@/components/terminal-pane/pty-dispatcher' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -29,7 +30,6 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' import { - getRemoteRuntimeTerminalHandle, subscribeToRuntimeTerminalData, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -38,6 +38,14 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +function runBestEffortCleanup(action: () => void): void { + try { + action() + } catch { + // Preserve the launch/setup error that triggered cleanup. + } +} + export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs ): Promise { @@ -75,46 +83,23 @@ export async function launchAgentBackgroundSession( const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - let startupPlan: AgentStartupPlan | null = null - let pasteDraftAfterLaunch: string | null = null - if (hasPrompt && isFollowupPath) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - agentArgs, - agentEnv, - platform: launchPlatform, - isRemote, - allowEmptyPromptLaunch: true - }) - pasteDraftAfterLaunch = trimmedPrompt - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: hasPrompt ? trimmedPrompt : '', - cmdOverrides, - agentArgs, - agentEnv, - platform: launchPlatform, - isRemote, - allowEmptyPromptLaunch: !hasPrompt - }) - } + const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + cmdOverrides, + agentArgs, + agentEnv, + platform: launchPlatform, + isRemote, + allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + }) if (!startupPlan) { return null } // Why: automation runs should start without revealing the workspace. // Spawn the PTY immediately, then attach an inactive tab to the live session. - // Background-mount the hidden worktree first so its off-screen terminal surface - // gets a measurable layout box and the eager PTY buffer flushes on the first - // mount — mirroring the renderer-backed Codex startup path in useIpcEvents. - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId } - }) - ) const tab = store.createTab(worktreeId, undefined, undefined, { activate: false, recordInteraction: false @@ -161,6 +146,36 @@ export async function launchAgentBackgroundSession( getSettingsForWorktreeRuntimeOwner(store, worktreeId) ) let ptyId = '' + let runtimeTerminalHandle: string | null = null + let exitHandled = false + let eagerPtyBuffer: EagerPtyHandle | null = null + let unsubscribeExit = (): void => {}, + unsubscribeData = (): void => {} + const handleExit = (exitPtyId: string, code: number): void => { + if (exitHandled) { + return + } + exitHandled = true + unsubscribeExit() + unsubscribeData() + sshStartupDelivery.clear() + useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + useAppStore.getState().clearAgentLaunchConfig(paneKey) + onExit?.(exitPtyId, code) + } + const processAgentStatus = createAgentStatusOscProcessor() + const handleData = (data: string): void => { + data = sshStartupDelivery.handleData(data) + onData?.(data) + sshStartupDelivery.schedule(ptyId) + const processed = processAgentStatus(data) + for (const payload of processed.payloads) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { + launchToken + }) + onAgentStatus?.(payload) + } + } try { if (runtimeTarget.kind === 'environment') { // Why: runtime environments execute on the server; using local pty.spawn @@ -186,7 +201,8 @@ export async function launchAgentBackgroundSession( }, { timeoutMs: 15_000 } ) - ptyId = toRemoteRuntimePtyId(created.terminal.handle, runtimeTarget.environmentId) + runtimeTerminalHandle = created.terminal.handle + ptyId = toRemoteRuntimePtyId(runtimeTerminalHandle, runtimeTarget.environmentId) } else { const result = await window.api.pty.spawn({ cols: 120, @@ -220,93 +236,92 @@ export async function launchAgentBackgroundSession( }) } } + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) + if (agent === 'command-code' && hasPrompt && !isFollowupPath) { + // Why: Command Code does not expose a prompt-start hook; seed working for + // hidden prompt launches so sidebar/activity surfaces do not stay idle. + store.setAgentStatus( + paneKey, + { + state: 'working', + prompt: trimmedPrompt, + agentType: agent + }, + undefined, + undefined, + undefined, + { launchConfig: startupPlan.launchConfig, launchToken } + ) + } + + if (runtimeTarget.kind === 'environment') { + if (!runtimeTerminalHandle) { + throw new Error('Runtime terminal id is invalid.') + } + unsubscribeData = await subscribeToRuntimeTerminalData( + store.settings, + ptyId, + `desktop:background:${tab.id}`, + handleData + ) + void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( + runtimeTarget, + 'terminal.wait', + { terminal: runtimeTerminalHandle, for: 'exit' }, + { timeoutMs: 24 * 60 * 60 * 1000 } + ) + .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) + .catch(() => {}) + } else { + eagerPtyBuffer = registerEagerPtyBuffer(ptyId, handleExit) + unsubscribeData = subscribeToPtyData(ptyId, handleData) + // Why: opening the workspace attaches a real terminal transport and disposes + // the eager exit handler. This sidecar keeps automation completion tracking + // alive regardless of whether the tab is hidden or mounted. + unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) + } + + // Why: mount only after the explicit PTY is bound. Mounting at the earlier + // createTab boundary lets a slow SSH/remote spawn race TerminalPane's fresh + // spawn path and launch the agent twice. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + + if (pasteDraftAfterLaunch !== null) { + void pasteDraftWhenAgentReady({ + tabId: tab.id, + content: pasteDraftAfterLaunch, + agent, + submit: true, + onTimeout: () => showAutomationPromptNotSentToast(agent) + }) + } + + return { tabId: tab.id, paneKey, ptyId, startupPlan } } catch (error) { - store.closeTab(tab.id, { recordInteraction: false }) + // Why: terminal creation and stream subscription are separate remote calls. + // A failure between them must not strand an invisible runtime terminal. + exitHandled = true + runBestEffortCleanup(unsubscribeExit) + runBestEffortCleanup(unsubscribeData) + runBestEffortCleanup(() => eagerPtyBuffer?.dispose()) + runBestEffortCleanup(() => sshStartupDelivery.clear()) + runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId)) + runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey)) + if (ptyId) { + try { + if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) { + await callRuntimeRpc(runtimeTarget, 'terminal.close', { + terminal: runtimeTerminalHandle + }) + } else if (runtimeTarget.kind === 'local') { + await window.api.pty.kill(ptyId) + } + } catch { + // Best-effort close; retiring the invalid hidden tab must still proceed. + } + } + runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false })) throw error } - store.updateTabPtyId(tab.id, ptyId) - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) - if (agent === 'command-code' && hasPrompt && !isFollowupPath) { - // Why: Command Code does not expose a prompt-start hook; seed working for - // hidden prompt launches so sidebar/activity surfaces do not stay idle. - store.setAgentStatus( - paneKey, - { - state: 'working', - prompt: trimmedPrompt, - agentType: agent - }, - undefined, - undefined, - undefined, - { launchConfig: startupPlan.launchConfig, launchToken } - ) - } - let exitHandled = false - let unsubscribeExit = (): void => {}, - unsubscribeData = (): void => {} - const handleExit = (ptyId: string, code: number): void => { - if (exitHandled) { - return - } - exitHandled = true - unsubscribeExit() - unsubscribeData() - sshStartupDelivery.clear() - useAppStore.getState().clearTabPtyId(tab.id, ptyId) - useAppStore.getState().clearAgentLaunchConfig(paneKey) - onExit?.(ptyId, code) - } - const processAgentStatus = createAgentStatusOscProcessor() - const handleData = (data: string): void => { - data = sshStartupDelivery.handleData(data) - onData?.(data) - sshStartupDelivery.schedule(ptyId) - const processed = processAgentStatus(data) - for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { - launchToken - }) - onAgentStatus?.(payload) - } - } - if (runtimeTarget.kind === 'environment') { - unsubscribeData = await subscribeToRuntimeTerminalData( - store.settings, - ptyId, - `desktop:background:${tab.id}`, - handleData - ) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - if (!terminal) { - throw new Error('Runtime terminal id is invalid.') - } - void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( - runtimeTarget, - 'terminal.wait', - { terminal, for: 'exit' }, - { timeoutMs: 24 * 60 * 60 * 1000 } - ) - .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) - .catch(() => {}) - } else { - registerEagerPtyBuffer(ptyId, handleExit) - unsubscribeData = subscribeToPtyData(ptyId, handleData) - // Why: opening the workspace attaches a real terminal transport and disposes - // the eager exit handler. This sidecar keeps automation completion tracking - // alive regardless of whether the tab is hidden or mounted. - unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) - } - - if (pasteDraftAfterLaunch !== null) { - void pasteDraftWhenAgentReady({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: true, - onTimeout: () => showAutomationPromptNotSentToast(agent) - }) - } - - return { tabId: tab.id, paneKey, ptyId, startupPlan } } 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 index bc5affb0c50..6fdec77f76c 100644 --- 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 @@ -2,6 +2,7 @@ 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' +import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' const initialAppStoreState = useAppStore.getState() @@ -67,6 +68,58 @@ describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => expect(state.activeTabType).toBe('browser') }) + it('skips and preserves a record whose claim an in-place wake already consumed', () => { + // Regression (#7906): a mounted hibernated pane that consumed the mobile + // wake starts its own in-place --resume; the generic resume must neither + // launch a second tab for that provider session nor clear the record (the + // in-place spawn clears it on success). + 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, + skipClaimKeys: new Set([getProviderSessionClaimKey(record)]) + }) + + expect(launched).toBe(0) + const state = useAppStore.getState() + expect(state.tabsByWorktree['wt-1']).toHaveLength(1) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + + it('reports each launched resume tab through onSessionLaunched', () => { + 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 launchedTabIds: string[] = [] + resumeSleepingAgentSessionsForWorktree('wt-1', { + suppressNavigation: true, + onSessionLaunched: (tabId) => launchedTabIds.push(tabId) + }) + + const resumedTab = useAppStore + .getState() + .tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + // Why: the dispatcher background-mounts exactly these tabs — an + // activate:false tab otherwise never mounts and its startup never runs. + expect(resumedTab).toBeDefined() + expect(launchedTabIds).toEqual([resumedTab?.id]) + }) + 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. diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index ba283ab370b..b73d4b02fe6 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -1,127 +1,20 @@ -import { toast } from 'sonner' import { useAppStore } from '@/store' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' -import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' -import { isWslUncPath } from '../../../shared/wsl-paths' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' import type { AgentProviderSessionMetadata, SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import { translate } from '@/i18n/i18n' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' import { getProviderSessionClaimKey, isPassiveCompletedHibernationEvidence, recordPaneIsOwnedByPreservedPane } from './sleeping-agent-pane-ownership' +import { + launchSleepingAgentSession, + type ResumeSleepingAgentSessionsOptions +} from './sleeping-agent-session-launch' -function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { - const state = useAppStore.getState() - const worktree = state.getKnownWorktreeById(worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { - return 'linux' - } - return CLIENT_PLATFORM -} - -function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { - const state = useAppStore.getState() - const termIds = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = state.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((f) => f.id) - const browserIds = (state.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) - const base = reconcileTabOrder( - state.tabBarOrderByWorktree[worktreeId], - termIds, - editorIds, - browserIds - ) - const order = base.filter((id) => id !== tabId) - order.push(tabId) - state.setTabBarOrder(worktreeId, order) -} - -// 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({ - agent: record.agent, - providerSession: record.providerSession, - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: - launchConfig !== undefined - ? launchConfig.agentArgs - : resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs), - agentEnv: - launchConfig !== undefined - ? launchConfig.agentEnv - : resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv), - ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - platform: getResumeLaunchPlatform(record.worktreeId) - }) - if (!startupPlan) { - toast.error( - translate( - 'auto.lib.resume.sleeping.agent.session.f235f604fd', - 'This agent session cannot be resumed.' - ) - ) - return false - } - - const tab = state.createTab(record.worktreeId, undefined, undefined, { - launchAgent: record.agent, - ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) - }) - state.queueTabStartupCommand(tab.id, { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - resumeProviderSession: record.providerSession, - launchAgent: record.agent, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - showSessionRestoredBanner: true, - telemetry: { - agent_kind: tuiAgentToAgentKind(record.agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - }) - state.claimAutomaticAgentResume(tab.id, { - worktreeId: record.worktreeId, - launchAgent: record.agent, - providerSession: record.providerSession - }) - state.clearSleepingAgentSession(record.paneKey) - if (!options?.suppressNavigation) { - state.setActiveTabType('terminal') - } - appendTabToWorktreeOrder(record.worktreeId, tab.id) - return true -} +export type { ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' function clearPassiveCompletedRecordsForClaimKey( records: readonly SleepingAgentSessionRecord[], @@ -249,7 +142,7 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): export function resumeSleepingAgentSessionsForWorktree( worktreeId: string, - options?: { suppressNavigation?: boolean } + options?: ResumeSleepingAgentSessionsOptions ): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) @@ -272,6 +165,12 @@ export function resumeSleepingAgentSessionsForWorktree( continue } const claimKey = getProviderSessionClaimKey(record) + // Why: a mounted pane already consumed (or latched) the in-place + // hibernation wake for this session; its record clears when that spawn + // succeeds. Launching or clearing here would double-resume the session. + if (options?.skipClaimKeys?.has(claimKey)) { + continue + } if (isInvalidWorktreeActivationRecord(record)) { state.clearSleepingAgentSession(record.paneKey) continue diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts new file mode 100644 index 00000000000..fea4c3d5de8 --- /dev/null +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -0,0 +1,128 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../shared/tui-agent-launch-defaults' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { translate } from '@/i18n/i18n' + +export type ResumeSleepingAgentSessionsOptions = { + suppressNavigation?: boolean + /** Provider-session claim keys already woken in place by mounted panes + * (WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT). Their sleeping records are + * cleared only after the in-place spawn succeeds, so the generic resume + * must neither launch nor clear them here. */ + skipClaimKeys?: ReadonlySet + /** Called with the tab id of each freshly launched resume tab, so + * navigation-suppressed callers can background-mount exactly those tabs. */ + onSessionLaunched?: (tabId: string) => void +} + +function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { + const state = useAppStore.getState() + const worktree = state.getKnownWorktreeById(worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } + if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { + return 'linux' + } + return CLIENT_PLATFORM +} + +function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { + const state = useAppStore.getState() + const termIds = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = state.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((f) => f.id) + const browserIds = (state.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) + const base = reconcileTabOrder( + state.tabBarOrderByWorktree[worktreeId], + termIds, + editorIds, + browserIds + ) + const order = base.filter((id) => id !== tabId) + order.push(tabId) + state.setTabBarOrder(worktreeId, order) +} + +// 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. +export function launchSleepingAgentSession( + record: SleepingAgentSessionRecord, + options?: ResumeSleepingAgentSessionsOptions +): boolean { + const state = useAppStore.getState() + const launchConfig = record.launchConfig + const startupPlan = buildAgentResumeStartupPlan({ + agent: record.agent, + providerSession: record.providerSession, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + launchConfig !== undefined + ? launchConfig.agentArgs + : resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs), + agentEnv: + launchConfig !== undefined + ? launchConfig.agentEnv + : resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv), + ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + platform: getResumeLaunchPlatform(record.worktreeId) + }) + if (!startupPlan) { + toast.error( + translate( + 'auto.lib.resume.sleeping.agent.session.f235f604fd', + 'This agent session cannot be resumed.' + ) + ) + return false + } + + const tab = state.createTab(record.worktreeId, undefined, undefined, { + launchAgent: record.agent, + ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) + }) + state.queueTabStartupCommand(tab.id, { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + resumeProviderSession: record.providerSession, + launchAgent: record.agent, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + showSessionRestoredBanner: true, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }) + state.claimAutomaticAgentResume(tab.id, { + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }) + state.clearSleepingAgentSession(record.paneKey) + if (!options?.suppressNavigation) { + state.setActiveTabType('terminal') + } + appendTabToWorktreeOrder(record.worktreeId, tab.id) + options?.onSessionLaunched?.(tab.id) + return true +} 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 index 4fbe281dfbc..67b82dbbb11 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -3,43 +3,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail, + type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' +import type { ResumeSleepingAgentSessionsOptions } from './resume-sleeping-agent-session' -const resumeSpy = vi.fn() +const resumeSpy = vi.fn< + (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number +>(() => 0) vi.mock('./resume-sleeping-agent-session', () => ({ - resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) => - resumeSpy(worktreeId, options) + resumeSleepingAgentSessionsForWorktree: ( + worktreeId: string, + options?: ResumeSleepingAgentSessionsOptions + ) => 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) + isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record), + recordPaneIsOwnedByPreservedPane: () => false, + getProviderSessionClaimKey: (record: { + worktreeId: string + agent?: string + providerSession?: { key?: string; id?: string } + }) => + `${record.worktreeId}\0${record.agent ?? 'agent'}\0${record.providerSession?.key ?? 'session_id'}\0${record.providerSession?.id ?? 'session'}` })) -let sleepingRecords: Record = {} +let sleepingRecords: Record = {} +let terminalTabsByWorktree: Record = {} +const clearSleepingAgentSessionsByPaneKey = vi.fn((paneKeys: readonly string[]) => { + for (const paneKey of paneKeys) { + delete sleepingRecords[paneKey] + } +}) vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords }) + getState: () => ({ + sleepingAgentSessionsByPaneKey: sleepingRecords, + tabsByWorktree: terminalTabsByWorktree, + clearSleepingAgentSessionsByPaneKey + }) } })) -import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' +import { + createBackgroundSleepingAgentWakeDispatcher, + wakeSleepingAgentsForWorktreeInBackground +} from './wake-sleeping-agents-in-background' -function recordEvents(): { events: string[]; stop: () => void } { +type RecordedEvents = { + events: string[] + mountDetails: BackgroundMountTerminalWorktreeDetail[] + wakeDetails: WakeHibernatedAgentsWorktreeDetail[] + stop: () => void +} + +function recordEvents(): RecordedEvents { const events: string[] = [] + const mountDetails: BackgroundMountTerminalWorktreeDetail[] = [] + const wakeDetails: WakeHibernatedAgentsWorktreeDetail[] = [] const onWake = (event: Event): void => { - events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + const detail = (event as CustomEvent).detail + events.push(`wake:${detail.worktreeId}`) + wakeDetails.push(detail) } const onMount = (event: Event): void => { - events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + const detail = (event as CustomEvent).detail + events.push(`mount:${detail.worktreeId}`) + mountDetails.push(detail) } window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) return { events, + mountDetails, + wakeDetails, stop: () => { window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) @@ -49,17 +91,52 @@ function recordEvents(): { events: string[]; stop: () => void } { beforeEach(() => { sleepingRecords = {} + terminalTabsByWorktree = {} + clearSleepingAgentSessionsByPaneKey.mockClear() isPassiveSpy.mockReset() resumeSpy.mockReset() + resumeSpy.mockReturnValue(0) }) afterEach(() => { resumeSpy.mockReset() }) +describe('createBackgroundSleepingAgentWakeDispatcher', () => { + it('deduplicates early wakes and drains them once workspace hydration completes', () => { + let workspaceSessionReady = false + let readinessListener: (() => void) | null = null + const unsubscribe = vi.fn() + const wake = vi.fn() + const dispatcher = createBackgroundSleepingAgentWakeDispatcher({ + isWorkspaceSessionReady: () => workspaceSessionReady, + subscribeToStore: (listener) => { + readinessListener = listener + return unsubscribe + }, + wake + }) + + dispatcher.request('wt-cold') + dispatcher.request('wt-cold') + expect(wake).not.toHaveBeenCalled() + + workspaceSessionReady = true + ;(readinessListener as unknown as () => void)() + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(wake).toHaveBeenCalledOnce() + expect(wake).toHaveBeenCalledWith('wt-cold') + + dispatcher.request('wt-ready') + expect(wake).toHaveBeenLastCalledWith('wt-ready') + dispatcher.dispose() + }) +}) + describe('wakeSleepingAgentsForWorktreeInBackground', () => { - it('fires wake, background-mount, then resume when a passive record exists', () => { - sleepingRecords = { k1: { worktreeId: 'wt-1' } } + it('fires wake, targeted background-mount, then resume when a passive record exists', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } isPassiveSpy.mockReturnValue(true) const rec = recordEvents() @@ -69,26 +146,166 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => { // (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']) + // Why: the mount targets only the sleeping record's tab, so one slept agent + // does not permanently mount every saved tab in the worktree. + expect(rec.mountDetails[0]?.tabIds).toEqual(['tab-a']) // (c) non-passive records resume with navigation suppressed (INV-2). - expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + expect(resumeSpy).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ suppressNavigation: true }) + ) }) - it('skips background-mount when only non-passive records exist', () => { - sleepingRecords = { k1: { worktreeId: 'wt-1' } } + it('falls back to a whole-worktree mount when a passive record has no resolvable tab', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'not-a-pane-key' } } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + expect(rec.mountDetails[0]?.tabIds).toBeUndefined() + }) + + it('mounts one canonical tab and clears cold aliases for the same provider session', () => { + sleepingRecords = { + 'tab-a:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-a:leaf-1', + tabId: 'tab-a', + capturedAt: 1, + updatedAt: 1 + } as never, + 'tab-b:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-b:leaf-1', + tabId: 'tab-b', + capturedAt: 2, + updatedAt: 2 + } as never + } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-a'] }]) + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledWith(['tab-b:leaf-1']) + expect(sleepingRecords).toHaveProperty('tab-a:leaf-1') + expect(sleepingRecords).not.toHaveProperty('tab-b:leaf-1') + }) + + it('prefers a live duplicate tab when the oldest alias tab is gone', () => { + sleepingRecords = { + 'missing-tab:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'missing-tab:leaf-1', + tabId: 'missing-tab', + capturedAt: 1, + updatedAt: 1 + } as never, + 'live-tab:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'live-tab:leaf-1', + tabId: 'live-tab', + capturedAt: 2, + updatedAt: 2 + } as never + } + terminalTabsByWorktree = { 'wt-1': [{ id: 'live-tab' }] } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['live-tab'] }]) + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledWith(['missing-tab:leaf-1']) + }) + + it('clears many cold aliases in one store action', () => { + sleepingRecords = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => { + const tabId = `tab-${index}` + return [ + `${tabId}:leaf-1`, + { + worktreeId: 'wt-1', + paneKey: `${tabId}:leaf-1`, + tabId, + capturedAt: index, + updatedAt: index + } as never + ] + }) + ) + isPassiveSpy.mockReturnValue(true) + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledOnce() + expect(clearSleepingAgentSessionsByPaneKey.mock.calls[0]?.[0]).toHaveLength(99) + expect(Object.keys(sleepingRecords)).toEqual(['tab-0:leaf-1']) + }) + + it('background-mounts the tabs the suppressed resume launches for non-passive records', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } + isPassiveSpy.mockReturnValue(false) + resumeSpy.mockImplementation((_worktreeId, options) => { + options?.onSessionLaunched?.('tab-new') + return 1 + }) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: the resume tab is created with activate:false, so nothing else + // mounts it — without this mount the queued --resume never gets a PTY. + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + expect(rec.mountDetails[0]?.tabIds).toEqual(['tab-new']) + }) + + it('skips background-mount when only non-passive records exist and nothing launches', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } 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). + // Why: no passive record and no launched resume tab → nothing needs a + // mount (it would strand a plain shell / mount work). expect(rec.events).toEqual(['wake:wt-1']) - expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + expect(resumeSpy).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ suppressNavigation: true }) + ) + }) + + it('passes claims consumed by mounted panes to the generic resume as skipClaimKeys', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } + isPassiveSpy.mockReturnValue(false) + // A mounted pane consuming the in-place wake adds its claim key to the + // event detail — exactly what use-terminal-pane-lifecycle does. + const onWake = (event: Event): void => { + ;(event as CustomEvent).detail.wokenClaimKeys?.add( + 'claim-1' + ) + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + const options = resumeSpy.mock.calls[0]?.[1] + expect(options?.skipClaimKeys?.has('claim-1')).toBe(true) }) it('does nothing when the worktree has no sleeping records', () => { - sleepingRecords = { k1: { worktreeId: 'other-wt' } } + sleepingRecords = { k1: { worktreeId: 'other-wt', paneKey: 'tab-a:leaf-1' } } const rec = recordEvents() wakeSleepingAgentsForWorktreeInBackground('wt-1') diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index 05369adbfe1..d2543fc1a8e 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -1,25 +1,164 @@ import { - BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, - type BackgroundMountTerminalWorktreeDetail, type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { useAppStore } from '@/store' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' -import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence, + recordPaneIsOwnedByPreservedPane +} from './sleeping-agent-pane-ownership' + +type BackgroundSleepingAgentWakeDispatcherOptions = { + isWorkspaceSessionReady?: () => boolean + subscribeToStore?: (listener: () => void) => () => void + wake?: (worktreeId: string) => void +} + +/** + * Buffers main's one-shot mobile wake until persisted sleeping records exist. + * Why: the renderer can attach its IPC listener before workspace hydration; + * dropping an early event leaves the phone connected to frozen slept panes. + */ +export function createBackgroundSleepingAgentWakeDispatcher( + options: BackgroundSleepingAgentWakeDispatcherOptions = {} +): { request: (worktreeId: string) => void; dispose: () => void } { + const pendingWorktreeIds = new Set() + const isWorkspaceSessionReady = + options.isWorkspaceSessionReady ?? (() => useAppStore.getState().workspaceSessionReady) + const subscribeToStore = + options.subscribeToStore ?? ((listener) => useAppStore.subscribe(listener)) + const wake = options.wake ?? wakeSleepingAgentsForWorktreeInBackground + let unsubscribeReadiness: (() => void) | null = null + let disposed = false + + const flushWhenReady = (): void => { + if (disposed || !isWorkspaceSessionReady()) { + return + } + const worktreeIds = [...pendingWorktreeIds] + pendingWorktreeIds.clear() + unsubscribeReadiness?.() + unsubscribeReadiness = null + for (const worktreeId of worktreeIds) { + wake(worktreeId) + } + } + + return { + request(worktreeId) { + if (disposed || !worktreeId) { + return + } + if (isWorkspaceSessionReady()) { + wake(worktreeId) + return + } + pendingWorktreeIds.add(worktreeId) + unsubscribeReadiness ??= subscribeToStore(flushWhenReady) + }, + dispose() { + disposed = true + pendingWorktreeIds.clear() + unsubscribeReadiness?.() + unsubscribeReadiness = null + } + } +} + +function getSleepingRecordTabId(record: SleepingAgentSessionRecord): string | null { + return ( + record.tabId ?? + parsePaneKey(record.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(record.paneKey)?.tabId ?? + null + ) +} + +function dispatchBackgroundMount(worktreeId: string, tabIds: readonly string[] | undefined): void { + requestBackgroundTerminalWorktreeMount({ worktreeId, ...(tabIds ? { tabIds } : {}) }) +} + +function getCanonicalPassiveWakeRecords( + records: readonly SleepingAgentSessionRecord[], + alreadyClaimed: ReadonlySet +): SleepingAgentSessionRecord[] { + const activeClaimKeys = new Set( + records + .filter((record) => !isPassiveCompletedHibernationEvidence(record)) + .map(getProviderSessionClaimKey) + ) + const recordsByClaim = new Map() + for (const record of records) { + if (!isPassiveCompletedHibernationEvidence(record)) { + continue + } + const claimKey = getProviderSessionClaimKey(record) + if (alreadyClaimed.has(claimKey) || activeClaimKeys.has(claimKey)) { + continue + } + const grouped = recordsByClaim.get(claimKey) ?? [] + grouped.push(record) + recordsByClaim.set(claimKey, grouped) + } + + const canonicalRecords: SleepingAgentSessionRecord[] = [] + const duplicatePaneKeys: string[] = [] + const state = useAppStore.getState() + for (const grouped of recordsByClaim.values()) { + const ordered = grouped + .slice() + .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) + const liveTabIds = new Set( + (state.tabsByWorktree[grouped[0]?.worktreeId ?? ''] ?? []).map((tab) => tab.id) + ) + const canonical = + ordered.find((record) => recordPaneIsOwnedByPreservedPane(record, state)) ?? + ordered.find((record) => { + const tabId = getSleepingRecordTabId(record) + return tabId !== null && liveTabIds.has(tabId) + }) ?? + ordered.find((record) => getSleepingRecordTabId(record) !== null) ?? + ordered[0] + if (!canonical) { + continue + } + canonicalRecords.push(canonical) + for (const duplicate of grouped) { + if (duplicate !== canonical) { + // Why: two cold panes mount after the event-scoped claim collector is + // gone. Keep one provider-session record so only one can issue resume. + duplicatePaneKeys.push(duplicate.paneKey) + } + } + } + state.clearSleepingAgentSessionsByPaneKey(duplicatePaneKeys) + return canonicalRecords +} /** * 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: + * Runs up to four 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; + * wake mechanism, since those records are passive for path C). Panes that + * consume — or latch, when the wake races the hibernation kill — the + * in-place wake claim their provider sessions via the event detail; + * (b) background-mount the tabs holding passive hibernated records that are + * NOT currently mounted (post-restart / evicted) so they take the + * fresh-connect cold-restore path. The mount is targeted by tabId so one + * sleeping pane does not permanently mount every saved tab; * (c) resume the non-passive record classes (manual sleep of a still-working - * agent, `origin: 'quit'`) with navigation suppressed. + * agent, `origin: 'quit'`) with navigation suppressed, skipping the + * claims from (a); + * (d) background-mount the tabs (c) created — they are `activate: false`, so + * nothing else would mount them and their queued `--resume` startup + * would otherwise never reach a PTY. * Woken PTYs auto-publish to mobile via the renderer graph republish, so no * spawn is awaited. */ @@ -34,24 +173,40 @@ export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): v return } + const wokenClaimKeys = new Set() window.dispatchEvent( new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { - detail: { worktreeId } + detail: { worktreeId, wokenClaimKeys } }) ) // 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 } - } - ) + // that needs a fresh-connect cold-restore (step b). Non-passive records are + // recovered by step (c) into a fresh tab, mounted in step (d). + const passiveTabIds = new Set() + let hasUntargetablePassiveRecord = false + for (const record of getCanonicalPassiveWakeRecords(worktreeRecords, wokenClaimKeys)) { + const tabId = getSleepingRecordTabId(record) + if (tabId) { + passiveTabIds.add(tabId) + } else { + hasUntargetablePassiveRecord = true + } + } + if (passiveTabIds.size > 0 || hasUntargetablePassiveRecord) { + // Why: a record whose tab cannot be resolved falls back to the untargeted + // whole-worktree mount rather than silently never waking. + dispatchBackgroundMount( + worktreeId, + hasUntargetablePassiveRecord ? undefined : [...passiveTabIds] ) } - resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true }) + const launchedTabIds: string[] = [] + resumeSleepingAgentSessionsForWorktree(worktreeId, { + suppressNavigation: true, + skipClaimKeys: wokenClaimKeys, + onSessionLaunched: (tabId) => launchedTabIds.push(tabId) + }) + if (launchedTabIds.length > 0) { + dispatchBackgroundMount(worktreeId, launchedTabIds) + } } diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index 271fc9495b9..b773a0d7721 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -462,6 +462,55 @@ describe('captureAllSleepingAgentSessions', () => { }) }) + it('clears multiple sleeping records and launch configs in one update', () => { + const store = createTestStore() + const sleeping = { + 'tab-1:leaf-1': { paneKey: 'tab-1:leaf-1' }, + 'tab-2:leaf-2': { paneKey: 'tab-2:leaf-2' }, + 'tab-3:leaf-3': { paneKey: 'tab-3:leaf-3' } + } as unknown as AppState['sleepingAgentSessionsByPaneKey'] + const launchConfigs = { + 'tab-1:leaf-1': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + }, + 'tab-2:leaf-2': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + }, + 'tab-3:leaf-3': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + } + } as AppState['agentLaunchConfigByPaneKey'] + store.setState({ + sleepingAgentSessionsByPaneKey: sleeping, + agentLaunchConfigByPaneKey: launchConfigs + }) + let changedUpdates = 0 + const unsubscribe = store.subscribe((next, previous) => { + if ( + next.sleepingAgentSessionsByPaneKey !== previous.sleepingAgentSessionsByPaneKey || + next.agentLaunchConfigByPaneKey !== previous.agentLaunchConfigByPaneKey + ) { + changedUpdates += 1 + } + }) + + store + .getState() + .clearSleepingAgentSessionsByPaneKey(['tab-1:leaf-1', 'tab-2:leaf-2', 'tab-2:leaf-2']) + unsubscribe() + + const state = store.getState() + expect(changedUpdates).toBe(1) + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-3:leaf-3']) + expect(Object.keys(state.agentLaunchConfigByPaneKey)).toEqual(['tab-3:leaf-3']) + }) + it('clears launch config registry entries when invalid sleeping sessions are pruned', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 57cddf6a8f2..06b611a8b7f 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -192,6 +192,7 @@ export type AgentStatusSlice = { * quit flush so provider session ids survive an app restart. */ captureAllSleepingAgentSessions: () => void clearSleepingAgentSession: (paneKey: string) => void + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void @@ -866,6 +867,41 @@ export const createAgentStatusSlice: StateCreator { + if (paneKeys.length === 0) { + return + } + const uniquePaneKeys = new Set(paneKeys) + set((s) => { + let nextSleeping = s.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + for (const paneKey of uniquePaneKeys) { + if (paneKey in nextSleeping) { + if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { + nextSleeping = { ...nextSleeping } + } + delete nextSleeping[paneKey] + } + if (paneKey in nextLaunchConfigs) { + if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { + nextLaunchConfigs = { ...nextLaunchConfigs } + } + delete nextLaunchConfigs[paneKey] + } + } + if ( + nextSleeping === s.sleepingAgentSessionsByPaneKey && + nextLaunchConfigs === s.agentLaunchConfigByPaneKey + ) { + return s + } + return { + sleepingAgentSessionsByPaneKey: nextSleeping, + agentLaunchConfigByPaneKey: nextLaunchConfigs + } + }) + } + return { agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, @@ -2073,31 +2109,8 @@ export const createAgentStatusSlice: StateCreator { - set((s) => { - const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - if (!hasSleepingRecord && !hasLaunchConfig) { - return s - } - const nextSleeping = hasSleepingRecord - ? { ...s.sleepingAgentSessionsByPaneKey } - : s.sleepingAgentSessionsByPaneKey - if (hasSleepingRecord) { - delete nextSleeping[paneKey] - } - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - return { - sleepingAgentSessionsByPaneKey: nextSleeping, - agentLaunchConfigByPaneKey: nextLaunchConfigs - } - }) - }, + clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), + clearSleepingAgentSessionsByPaneKey, clearSleepingAgentSessionsByWorktree: (worktreeId) => { set((s) => { diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 7cc30477126..db9bee3a8d6 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -1,4 +1,7 @@ import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildWslInteractiveLoginShellCommand, @@ -64,6 +67,61 @@ describe('wsl login shell command helpers', () => { expect(command).toContain("printf '\\''hello'\\''") }) + it.skipIf(process.platform === 'win32')( + 'resolves env-node launchers from the current login-shell PATH on every run', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-login-codex-')) + const tools = join(root, 'tools') + const loginBin = join(root, 'login') + const v1Bin = join(root, 'nvm-v1') + const v2Bin = join(root, 'nvm-v2') + mkdirSync(tools) + mkdirSync(loginBin) + mkdirSync(v1Bin) + mkdirSync(v2Bin) + const loginShell = join(loginBin, 'bash') + writeFileSync( + join(tools, 'getent'), + `#!/bin/sh\nprintf '%s\\n' "user:x:1000:1000::/home/user:$ORCA_TEST_LOGIN_SHELL"\n` + ) + writeFileSync( + loginShell, + '#!/bin/sh\nexport PATH="$ORCA_TEST_CODEX_BIN:/usr/bin:/bin"\nexec /bin/sh -c "$2"\n' + ) + for (const [bin, label] of [ + [v1Bin, 'v1'], + [v2Bin, 'v2'] + ] as const) { + writeFileSync(join(bin, 'codex'), '#!/usr/bin/env node\n') + writeFileSync(join(bin, 'node'), `#!/bin/sh\nprintf '%s' '${label}'\n`) + chmodSync(join(bin, 'codex'), 0o755) + chmodSync(join(bin, 'node'), 0o755) + } + chmodSync(join(tools, 'getent'), 0o755) + chmodSync(loginShell, 0o755) + + const command = buildWslLoginShellCommand('exec codex') + const run = (codexBin: string): string => + execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${tools}:/usr/bin:/bin`, + ORCA_TEST_LOGIN_SHELL: loginShell, + ORCA_TEST_CODEX_BIN: codexBin + } + }) + + try { + expect(run(v1Bin)).toBe('v1') + // The old launcher remains executable; current PATH precedence wins. + expect(run(v2Bin)).toBe('v2') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('preserves command-scoped environment variables through the outer WSL shell', () => { const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"') const escaped = escapeWslShCommandForWindows(command) From 79abeeaa2a053dc7455004504722fcedc921371a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 10 Jul 2026 00:57:01 -0400 Subject: [PATCH 150/152] fix(browser): allow CDP browser target attach (#7033) (#7891) * fix(browser): allow CDP browser target attach (#7033) * review: preserve nested CDP session identity Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/browser/cdp-ws-proxy.test.ts | 142 ++++++++++++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 90 ++++++++++++++-- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 60248d21b15..5c366eda727 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -89,6 +89,148 @@ describe('CdpWsProxy', () => { client.close() }) + it('routes Playwright nested browser and page sessions on the proxy path', async () => { + const client = await connect(endpoint) + + const primaryPageAttachResponse = await sendAndReceive(client, { + id: 31, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(primaryPageAttachResponse).toEqual({ + id: 31, + result: { sessionId: 'orca-proxy-session' } + }) + + const attachResponse = await sendAndReceive(client, { + id: 32, + method: 'Target.attachToBrowserTarget', + params: {} + }) + + expect(attachResponse).toEqual({ + id: 32, + result: { sessionId: 'orca-proxy-browser-session' } + }) + expect(getSendCommandMethods(mock)).not.toContain('Target.attachToBrowserTarget') + + const pageAttachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(pageAttachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + const sessionResponse = await sendAndReceive(client, { + id: 34, + method: 'Runtime.evaluate', + params: { expression: 'document.title' }, + sessionId: 'orca-proxy-session-2' + }) + + expect(sessionResponse).toEqual({ + id: 34, + result: {}, + sessionId: 'orca-proxy-session-2' + }) + expect(getSendCommandCalls(mock)).toContainEqual([ + 'Runtime.evaluate', + { expression: 'document.title' } + ]) + + const detachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(detachResponse).toEqual({ + id: 35, + result: {}, + sessionId: 'orca-proxy-browser-session' + }) + + const primaryPageResponse = await sendAndReceive(client, { + id: 36, + method: 'Runtime.evaluate', + params: { expression: 'document.URL' }, + sessionId: 'orca-proxy-session' + }) + + expect(primaryPageResponse).toEqual({ + id: 36, + result: {}, + sessionId: 'orca-proxy-session' + }) + + const browserSessionResponse = await sendAndReceive(client, { + id: 37, + method: 'Target.getTargets', + params: {}, + sessionId: 'orca-proxy-browser-session' + }) + + expect(browserSessionResponse).toMatchObject({ + id: 37, + sessionId: 'orca-proxy-browser-session' + }) + client.close() + }) + + it('preserves Target.attachToTarget and clears the synthetic session on Target.detachFromTarget', async () => { + const client = await connect(endpoint) + + const attachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(attachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session' } + }) + + const detachResponse = await sendAndReceive(client, { + id: 34, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session' } + }) + + expect(detachResponse).toEqual({ id: 34, result: {} }) + + const rootEventPromise = new Promise>((resolve) => { + client.once('message', (data) => resolve(JSON.parse(data.toString()))) + }) + mock.emit('message', {}, 'Runtime.executionContextCreated', { context: { id: 1 } }) + + const rootEvent = await rootEventPromise + expect(rootEvent).toEqual({ + method: 'Runtime.executionContextCreated', + params: { context: { id: 1 } } + }) + + const reattachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(reattachResponse).toEqual({ + id: 35, + result: { sessionId: 'orca-proxy-session-2' } + }) + client.close() + }) + it('returns an error instead of crashing when a command arrives after tab destruction', async () => { const client = await connect(endpoint) mock.destroy() diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 62e51342d26..99c1d554654 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -19,6 +19,7 @@ export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null + private readonly responseSessionIdsByClient = new WeakMap>() private detachClientListeners: (() => void) | null = null private port = 0 private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null @@ -27,6 +28,10 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly clientSessionIds = new Set() + private readonly clientBrowserSessionIds = new Set() + private nextClientSessionOrdinal = 0 + private nextClientBrowserSessionOrdinal = 0 private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -59,7 +64,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { - this.pdfStreams.clear() + this.clearClientState() this.client = null } } @@ -110,18 +115,45 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null - // Why: a pending focus belongs to the departing client; never replay it for the next one. - this.pendingDomFocusBySession.clear() - this.pdfStreams.clear() + this.clearClientState() + if (client) { + this.responseSessionIdsByClient.delete(client) + } client?.close() } + private clearClientState(): void { + // Why: session and focus state belongs to one websocket and must not cross client replacement. + this.pendingDomFocusBySession.clear() + this.pdfStreams.clear() + this.clientSessionId = undefined + this.clientSessionIds.clear() + this.clientBrowserSessionIds.clear() + this.nextClientSessionOrdinal = 0 + this.nextClientBrowserSessionOrdinal = 0 + } + private send(payload: unknown, client = this.client): void { + const responsePayload = client ? this.addResponseSessionId(payload, client) : payload if (client?.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(payload)) + client.send(JSON.stringify(responsePayload)) } } + private addResponseSessionId(payload: unknown, client: WebSocket): unknown { + if (typeof payload !== 'object' || payload === null) { + return payload + } + const clientId = (payload as { id?: unknown }).id + if (typeof clientId !== 'number') { + return payload + } + const responseSessionIds = this.responseSessionIdsByClient.get(client) + const sessionId = responseSessionIds?.get(clientId) + responseSessionIds?.delete(clientId) + return sessionId ? { ...payload, sessionId } : payload + } + private sendResult(clientId: number, result: unknown, client = this.client): void { this.send({ id: clientId, result }, client) } @@ -253,6 +285,13 @@ export class CdpWsProxy { return } const clientId = msg.id + const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map() + if (msg.sessionId) { + responseSessionIds.set(clientId, msg.sessionId) + } else { + responseSessionIds.delete(clientId) + } + this.responseSessionIdsByClient.set(client, responseSessionIds) if (msg.method === 'Target.getTargets') { this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client) @@ -264,14 +303,30 @@ export class CdpWsProxy { } if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') { if (msg.method === 'Target.detachFromTarget') { - this.clientSessionId = undefined + const detachedSessionId = msg.params?.sessionId + if (typeof detachedSessionId === 'string') { + this.clientSessionIds.delete(detachedSessionId) + this.clientBrowserSessionIds.delete(detachedSessionId) + if (detachedSessionId === this.clientSessionId) { + this.clientSessionId = this.clientSessionIds.values().next().value + } + } } this.sendResult(clientId, {}, client) return } + if (msg.method === 'Target.attachToBrowserTarget') { + // Why: Playwright needs a distinct browser session before it attaches to the selected page. + const sessionId = this.nextSyntheticBrowserSessionId() + this.clientBrowserSessionIds.add(sessionId) + this.sendResult(clientId, { sessionId }, client) + return + } if (msg.method === 'Target.attachToTarget') { - this.clientSessionId = 'orca-proxy-session' - this.sendResult(clientId, { sessionId: this.clientSessionId }, client) + const sessionId = this.nextSyntheticPageSessionId() + this.clientSessionIds.add(sessionId) + this.clientSessionId ??= sessionId + this.sendResult(clientId, { sessionId }, client) return } if (msg.method === 'Browser.getVersion') { @@ -363,7 +418,24 @@ export class CdpWsProxy { } private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { - return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const syntheticSession = + (msgSessionId && this.clientSessionIds.has(msgSessionId)) || + (msgSessionId && this.clientBrowserSessionIds.has(msgSessionId)) + return msgSessionId && !syntheticSession ? msgSessionId : undefined + } + + private nextSyntheticPageSessionId(): string { + this.nextClientSessionOrdinal += 1 + return this.nextClientSessionOrdinal === 1 + ? 'orca-proxy-session' + : `orca-proxy-session-${this.nextClientSessionOrdinal}` + } + + private nextSyntheticBrowserSessionId(): string { + this.nextClientBrowserSessionOrdinal += 1 + return this.nextClientBrowserSessionOrdinal === 1 + ? 'orca-proxy-browser-session' + : `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}` } private isActiveClient(client: WebSocket): boolean { From 008ad62d3fe92ef8aaaf63392c8fa8faed509ffb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:57:38 -0700 Subject: [PATCH 151/152] Add HTML preview-to-side support for single unstaged/staged diffs (#8018) - Move the preview button before the single-diff tooltip in the header - Extend canOpenPreviewToSide to allow single diffs (not commit diffs) when the modified file still exists on disk, since the preview renders the working-tree file rather than diff content - Add tests covering HTML edit tabs, unstaged diffs, deleted files, commit diffs, and non-HTML diffs --- .../components/editor/EditorPanelHeader.tsx | 50 +++++++------- .../editor/editor-panel-render-model.test.ts | 67 ++++++++++++++++++- .../editor/editor-panel-render-model.ts | 18 +++-- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index f47c9190ccd..23ce653055f 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -100,6 +100,31 @@ export function EditorPanelHeader({ onOpenMarkdownPreview={onOpenMarkdownPreview} onOpenContainingFolder={onOpenContainingFolder} /> + {canOpenPreviewToSide && ( + + + + + + + {translate( + 'auto.components.editor.EditorPanelHeader.fb8331694e', + 'Open Preview to the Side' + )} + + + + )} {isSingleDiff && ( @@ -149,31 +174,6 @@ export function EditorPanelHeader({ iconClassName="size-3" /> )} - {canOpenPreviewToSide && ( - - - - - - - {translate( - 'auto.components.editor.EditorPanelHeader.fb8331694e', - 'Open Preview to the Side' - )} - - - - )} {isDiffSurface && ( diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index 374e870e47f..88e3a539e63 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { OpenFile } from '@/store/slices/editor' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' +import type { GitStatusEntry } from '../../../../shared/types' import type { FileContent } from './editor-panel-content-types' import { getEditorPanelRenderModel } from './editor-panel-render-model' @@ -31,18 +32,82 @@ function renderModel(args: { editorDrafts?: Record markdownViewMode?: Record isChangesMode?: boolean + gitStatusByWorktree?: Record }) { return getEditorPanelRenderModel({ activeFile: args.activeFile ?? markdownFile(), fileContents: args.fileContents ?? { '/repo/README.md': textContent() }, editorDrafts: args.editorDrafts ?? {}, - gitStatusByWorktree: {}, + gitStatusByWorktree: args.gitStatusByWorktree ?? {}, gitBranchChangesByWorktree: {}, markdownViewMode: args.markdownViewMode ?? {}, isChangesMode: args.isChangesMode ?? false }) } +function htmlFile(overrides: Partial = {}): OpenFile { + return { + id: '/repo/mock.html', + filePath: '/repo/mock.html', + relativePath: 'mock.html', + worktreeId: 'wt-1', + language: 'html', + mode: 'edit', + isDirty: false, + ...overrides + } +} + +describe('getEditorPanelRenderModel HTML preview affordance', () => { + it('enables preview for HTML edit tabs', () => { + expect(renderModel({ activeFile: htmlFile(), fileContents: {} }).canOpenPreviewToSide).toBe( + true + ) + }) + + it('enables preview for single HTML diffs whose file exists on disk', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {}, + gitStatusByWorktree: { + 'wt-1': [{ path: 'mock.html', status: 'modified', area: 'unstaged' }] + } + }) + + expect(model.canOpenPreviewToSide).toBe(true) + }) + + it('disables preview for diffs of deleted HTML files', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {}, + gitStatusByWorktree: { + 'wt-1': [{ path: 'mock.html', status: 'deleted', area: 'unstaged' }] + } + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) + + it('disables preview for commit diffs whose content may not match disk', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'commit' } as Partial), + fileContents: {} + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) + + it('disables preview for non-HTML diffs', () => { + const model = renderModel({ + activeFile: markdownFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {} + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) +}) + describe('getEditorPanelRenderModel markdown export affordance', () => { it('enables export for rendered markdown edit tabs', () => { expect(renderModel({}).canExportMarkdownToPdf).toBe(true) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index be9738db6a4..c471b2e73ad 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -71,6 +71,11 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' && activeFile.diffSource === 'branch' ? (branchEntries.find((entry) => entry.path === activeFile.relativePath) ?? null) : null + const openFileState = getEditorHeaderOpenFileState( + activeFile, + matchingWorktreeEntry, + matchingBranchEntry + ) const markdownViewModes = getMarkdownViewModes({ language: resolvedLanguage, mode: activeFile.mode, @@ -141,16 +146,17 @@ export function getEditorPanelRenderModel({ isCombinedDiff, worktreeEntries, resolvedLanguage, - openFileState: getEditorHeaderOpenFileState( - activeFile, - matchingWorktreeEntry, - matchingBranchEntry - ), + openFileState, isMarkdown: resolvedLanguage === 'markdown', isMermaid: resolvedLanguage === 'mermaid', isCsv: resolvedLanguage === 'csv' || resolvedLanguage === 'tsv', isNotebook: resolvedLanguage === 'notebook', - canOpenPreviewToSide: activeFile.mode === 'edit' && canPreviewLanguage(resolvedLanguage), + // Why: the preview renders the on-disk file, so diff surfaces only get it + // when the modified side still exists on disk (canOpen excludes deleted + // files and commit diffs whose content may not match the working tree). + canOpenPreviewToSide: + canPreviewLanguage(resolvedLanguage) && + (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, hasViewModeToggle, availableEditorToggleModes, From 3a3e33f14b79cd40ebfbcc333b2b86035198f3ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:01:22 -0700 Subject: [PATCH 152/152] The `--sandbox` flag (or terminal sandboxing) in Google Antigravity is a (#8017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here is a summary of how the sandbox behaves on your macOS system: ### ⚙️ How it Works When `--sandbox` is enabled (either via the launch flag or the `enableTerminalSandbox` setting in your `settings.json`), terminal commands run inside a lightweight containment boundary: - **macOS Native Isolation**: It utilizes macOS's native `sandbox-exec` utility to restrict system calls, network sockets, and directory access. - **Secure File Boundaries**: File system writes are locked down to designated safe zones (such as your designated workspace or scratch directory). Access to critical system paths, private user data, and external network resources is restricted. --- ### 🛡️ Active Permissions for this Session In this current session, the permission model is configured as follows: | Action / Resource | Permission Status | Details / Paths | | :--- | :--- | :--- | | **Command Execution** | ✅ **Allowed** | Terminal command execution is enabled. | | **File Reads (Allowed)** | ✅ **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees`, `/skills`, `/builtin` | | **File Writes (Allowed)**| ✅ **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees` | | **Sensitive Files** | ⚠️ **Ask** | `.env`, `.npmrc`, `.vscode`, `.git-credentials`, etc. | | **Root/App Settings** | 🚫 **Denied** | Direct modifications to `/config` and main `.gemini` configurations | --- ### 🔧 Configuration and Management * **Persistent Settings**: To enable sandboxing by default for all future sessions, configure the `enableTerminalSandbox` setting in your `~/.gemini/antigravity-cli/settings.json`: ```json { "enableTerminalSandbox": true } ``` * **Dynamic Adjustments**: Within an active CLI (`agy`) session, you can run the `/permissions` slash command to view or modify your autonomy and sandboxing levels on the fly. > [!NOTE] > Running in sandbox mode provides an excellent balance of autonomy and security, allowing me to execute build commands, run test scripts, and manage project files safely without risk to your primary host environment. Please let me know if you would like me to set up a new project workspace or run any specific tasks within this session! --- mobile/mock-source-control-hub.html | 2 +- mobile/src/components/MobileMarkdown.tsx | 2 +- mobile/src/components/MobilePRSidebar.tsx | 58 +++++--- .../src/components/MobilePrComposeSheet.tsx | 5 +- .../pr-sidebar/MobilePrViewPanel.tsx | 2 + .../pr-sidebar/PRActionsSection.tsx | 124 +++++++++++------- .../pr-sidebar/PRCommentsSection.tsx | 16 ++- .../pr-sidebar/PRReviewersSection.tsx | 46 ++++--- .../src/components/pr-sidebar/PRSection.tsx | 20 ++- .../components/pr-sidebar/PRSidebarHeader.tsx | 110 +++++++++------- .../pr-sidebar/ReviewerPickerDrawer.tsx | 48 +++---- .../pr-sidebar/mobile-pr-sidebar-styles.ts | 41 +++++- .../pr-sidebar/pr-actions-styles.ts | 13 ++ .../pr-sidebar/pr-comment-composer-styles.ts | 3 +- mobile/src/session/github-pr-rpc.test.ts | 17 +++ mobile/src/session/github-pr-value-readers.ts | 13 +- mobile/src/session/mobile-pr-sidebar-state.ts | 15 +++ mobile/src/session/pr-actions-engine.test.ts | 45 +++++++ mobile/src/session/pr-actions-engine.ts | 21 ++- .../use-mobile-pr-sidebar-controller.test.ts | 25 ++++ .../use-mobile-pr-sidebar-controller.ts | 30 +++-- .../MobileSourceControlPanel.tsx | 14 +- .../mobile-source-control-actions.test.ts | 4 +- .../mobile-source-control-actions.ts | 2 +- .../mobile-source-control-hub-styles.ts | 28 ++-- .../mobile-source-control-hub-tab.ts | 2 +- src/main/github/client.ts | 71 ++++++++-- 27 files changed, 558 insertions(+), 219 deletions(-) diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html index 0a64d75ecbf..4e8035adef3 100644 --- a/mobile/mock-source-control-hub.html +++ b/mobile/mock-source-control-hub.html @@ -1165,7 +1165,7 @@ Changes - +
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 5ee7ca6b4e7..3200dcc5d71 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8 function openMarkdownUrl(url: string): void { const trimmed = url.trim() if (/^(https?:|mailto:)/i.test(trimmed)) { - void Linking.openURL(trimmed) + void Linking.openURL(trimmed).catch(() => {}) } } diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx index 932f72e262a..42829df0310 100644 --- a/mobile/src/components/MobilePRSidebar.tsx +++ b/mobile/src/components/MobilePRSidebar.tsx @@ -39,6 +39,8 @@ type Props = { gitStatus: MobileGitStatusResult | null headSha: string | null bottomInset?: number + // Hub chrome already shows open-on-web; hide the in-body icon there. + showOpenOnWeb?: boolean } // Mutation hooks run unconditionally here and gate internally until a PR is ready. @@ -52,17 +54,15 @@ export function MobilePRSidebar({ gitBranch, gitStatus, headSha, - bottomInset = 0 + bottomInset = 0, + showOpenOnWeb = true }: Props) { const branch = prSidebarRenderBranch(state) // prNumber is 0 until ready; the hook gates on `ready` so it never fires early. const prNumber = state.kind === 'ready' ? state.data.pr.number : 0 - const prRepo = - state.kind === 'ready' - ? state.data.pr.prRepo - ? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo } - : null - : null + // Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render + // reallocates and thrash-updates the mutation/comment/title hooks. + const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null const actions = useMobilePrActions({ client, connState, @@ -95,6 +95,9 @@ export function MobilePRSidebar({ style={{ flex: 1 }} contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]} keyboardShouldPersistTaps="handled" + // Why: root-comment / reply composers sit at the bottom of this scroll + // area; without keyboard insets the focused field stays under the keyboard. + automaticallyAdjustKeyboardInsets showsVerticalScrollIndicator={false} > ) @@ -129,7 +133,8 @@ function PrSidebarContent({ actions, commentActions, titleAction, - triage + triage, + showOpenOnWeb }: { branch: ReturnType state: PrSidebarState @@ -144,6 +149,7 @@ function PrSidebarContent({ commentActions: MobilePrCommentActions titleAction: MobilePrTitleAction triage: MobilePrAiTriage + showOpenOnWeb: boolean }) { if (branch === 'loading') { return ( @@ -209,6 +215,7 @@ function PrSidebarContent({ titleAction={titleAction} triage={triage} refetch={refetch} + showOpenOnWeb={showOpenOnWeb} /> ) } @@ -223,7 +230,8 @@ function PrSidebarSections({ commentActions, titleAction, triage, - refetch + refetch, + showOpenOnWeb }: { data: Extract['data'] client: RpcClient | null @@ -233,6 +241,7 @@ function PrSidebarSections({ titleAction: MobilePrTitleAction triage: MobilePrAiTriage refetch: () => void + showOpenOnWeb: boolean }) { const pr = data.pr // Bind the triage launchers to this PR's data; the prompt builders are pure so @@ -262,19 +271,30 @@ function PrSidebarSections({ isBusy: triage.isBusy('resolve-conflicts'), error: triage.error } + // One card for identity + actions so the ready PR isn't a stack of thin + // duplicate blocks (badge row, title, branches, then another action band). return ( <> - - {/* Conflicting-files section mirrors desktop order: directly below the header, - before actions/checks. Renders only when the PR has merge conflicts. */} + + + + + + + {/* Own titled section when present; null otherwise (no empty chrome). */} - {}) } diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index 5eb17e243d9..f587074e12e 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -62,6 +62,8 @@ export function MobilePrViewPanelBody({ gitStatus={gitStatus} headSha={headSha} bottomInset={insets.bottom} + // Hub header already hosts open-on-web while this segment is active. + showOpenOnWeb={false} /> ) diff --git a/mobile/src/components/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx index 86b910a08ca..5fae6803889 100644 --- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx @@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' import { unlinkMobilePr } from '../../source-control/mobile-pr-link' import { ConfirmModal } from '../ConfirmModal' -import { PRSection } from './PRSection' import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability' import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state' import { prActionsStyles as styles } from './pr-actions-styles' @@ -25,12 +24,13 @@ type Confirm = | { kind: 'merge'; method: GitHubPRMergeMethod } | { kind: 'state'; state: 'open' | 'closed' } -// Merge, auto-merge toggle, and close/reopen. Destructive actions route through -// ConfirmModal first (R5). The firing row shows a spinner in place of its icon -// and disables; other rows stay interactive (uniform visual). +// Merge primary; Close/Reopen + Unlink share one secondary row. No section title — +// button labels are self-explanatory and a header wasted a full row on mobile. export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) { const [confirm, setConfirm] = useState(null) const [unlinking, setUnlinking] = useState(false) + // Local unlink errors — unlink is not routed through the actions engine. + const [unlinkError, setUnlinkError] = useState(null) // Mobile keeps merge one-tap: use the repo default instead of surfacing a // desktop-style method picker in the narrow PR action stack. @@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const mergeBusy = actions.isBusy({ kind: 'merge' }) const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' }) const stateBusy = actions.isBusy({ kind: 'state' }) + const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy const showAutoMerge = avail.canAutoMerge && canShowMobilePRAutoMergeControl({ ...pr, autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true }) + const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink + const actionError = unlinkError ?? actions.error const unlink = useCallback(async (): Promise => { if (!client || unlinking) { return } setUnlinking(true) + setUnlinkError(null) try { const outcome = await unlinkMobilePr(client, worktreeId) if (outcome.ok) { onUnlinked() + } else { + setUnlinkError(outcome.error) } + } catch (err) { + setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.') } finally { setUnlinking(false) } @@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } if (!confirm) { return } + // Engine errors take over the shared error line after this; drop unlink text. + setUnlinkError(null) if (confirm.kind === 'merge') { actions.merge(confirm.method) } else { @@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const copy = confirmCopy() return ( - - {/* Merge controls only while the PR can still be merged (open/draft). */} + {avail.canMerge ? ( setConfirm({ kind: 'merge', method: effectiveMethod })} + onPress={() => { + setUnlinkError(null) + setConfirm({ kind: 'merge', method: effectiveMethod }) + }} disabled={mergeBusy} accessibilityRole="button" accessibilityLabel="Merge pull request" @@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Auto-merge toggle — optimistic, reverts on transient failure. */} {showAutoMerge ? ( Auto-merge when ready actions.setAutoMerge(!autoMerge, effectiveMethod)} + onPress={() => { + setUnlinkError(null) + actions.setAutoMerge(!autoMerge, effectiveMethod) + }} disabled={autoMergeBusy} accessibilityRole="switch" accessibilityState={{ checked: autoMerge }} @@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */} - {avail.canClose || avail.canReopen ? ( - setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })} - disabled={stateBusy} - accessibilityRole="button" - accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} - > - {stateBusy ? : null} - - {avail.canClose ? 'Close' : 'Reopen'} - - + {showSecondary ? ( + + {avail.canClose || avail.canReopen ? ( + { + setUnlinkError(null) + setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' }) + }} + disabled={stateBusy} + accessibilityRole="button" + accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} + > + {stateBusy ? : null} + + {avail.canClose ? 'Close' : 'Reopen'} + + + ) : null} + {avail.canUnlink ? ( + void unlink()} + disabled={unlinkBusy} + accessibilityRole="button" + accessibilityLabel="Unlink pull request" + > + {unlinking ? ( + + ) : ( + + )} + Unlink + + ) : null} + ) : null} - {/* Unlink the PR from this worktree. Disabled while another PR mutation is in - flight so clearing the link can't race a merge/close refetch. */} - {avail.canUnlink ? ( - void unlink()} - disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy} - accessibilityRole="button" - accessibilityLabel="Unlink pull request" - > - {unlinking ? ( - - ) : ( - - )} - Unlink - - ) : null} + {actionError ? {actionError} : null} - {actions.error ? {actions.error} : null} - - {/* A Modal is taken out of the flex flow, so it adds no body gap here. */} setConfirm(null)} /> - + ) } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index 051f1392e9d..b6c0a1009d6 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc' import { colors } from '../../theme/mobile-theme' import { canAddRootComment } from '../../session/pr-comment-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions' import { PRSection } from './PRSection' import { CommentMarkdown } from './CommentMarkdown' @@ -50,10 +51,15 @@ const COMMENT_PAGE = 12 // review comments, reactions, and collapsible resolved threads. export function PRCommentsSection({ details, prState, prRepo, actions }: Props) { // details is null while phase 2 (the heavy comments/body payload) is still loading. + // A synthetic placeholder means phase 2 failed — do not paint that as empty success. const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const body = details?.body ?? '' - const comments = useMemo(() => details?.comments ?? [], [details]) - const isPr = details?.item.type === 'pr' + const comments = useMemo( + () => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []), + [details] + ) + const isPr = details != null && !detailsFailed && details.item.type === 'pr' // Per-card action bundle (stable callbacks from the hook) — built once so the // memo'd cards don't re-render on unrelated timeline changes. @@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {loadingDetails ? ( + ) : detailsFailed ? ( + + Could not load description. Tap refresh to try again. + ) : body.trim() ? ( ) : ( @@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) > {loadingDetails ? ( + ) : detailsFailed ? ( + Could not load comments. Tap refresh to try again. ) : ( {comments.length === 0 ? ( diff --git a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx index ad16241c206..bc09ecd3502 100644 --- a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx +++ b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx @@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails } from '../../../../src/shared/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import { getPRReviewerRows } from './pr-checks-presentation' import { ReviewerPickerDrawer } from './ReviewerPickerDrawer' import { PRSection } from './PRSection' @@ -20,8 +21,17 @@ type Props = { // Requested reviewers + their latest review status, with a picker to request / // remove (optimistic add/remove via the actions hook). export function PRReviewersSection({ details, actions, client, worktreeId }: Props) { + // details === null means phase 2 (work-item payload) is still in flight — same + // signal Comments uses. Do not treat that as "no reviewers" or the section goes + // blank while checks (phase 1) already paint. A synthetic placeholder means + // phase 2 failed (no body/comments/reviews landed). + const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const authoritativeRows = useMemo( - () => (details?.item ? getPRReviewerRows(details.item) : []), + () => + details?.item && !isPrSidebarDetailsPlaceholder(details) + ? getPRReviewerRows(details.item) + : [], [details] ) const [pickerOpen, setPickerOpen] = useState(false) @@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro return author ? [author, ...logins] : logins }, [authoritativeRows, details]) - return ( - setPickerOpen(true)} - accessibilityRole="button" - accessibilityLabel="Add or remove reviewers" - > - - - } + const addButton = ( + setPickerOpen(true)} + accessibilityRole="button" + accessibilityLabel="Add or remove reviewers" > - {rows.length === 0 ? ( + + + ) + + return ( + + {loadingDetails ? ( + + + Loading reviewers… + + ) : detailsFailed ? ( + Could not load reviewers. Tap refresh to try again. + ) : rows.length === 0 ? ( No reviewers requested ) : ( rows.map((row) => { diff --git a/mobile/src/components/pr-sidebar/PRSection.tsx b/mobile/src/components/pr-sidebar/PRSection.tsx index a573258d41a..0ed5ce90416 100644 --- a/mobile/src/components/pr-sidebar/PRSection.tsx +++ b/mobile/src/components/pr-sidebar/PRSection.tsx @@ -3,22 +3,28 @@ import { Text, View } from 'react-native' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' type Props = { - title: string + // Optional: omit for self-explanatory sections (e.g. action buttons) so the + // header row doesn't waste vertical space on mobile. + title?: string // Optional trailing control(s) in the header row (e.g. add-reviewer, checks // summary + rerun). Rendered right-aligned opposite the title. trailing?: ReactNode children: ReactNode } -// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors -// the desktop PR page's card-with-header-divider so the sections read consistently. +// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the +// desktop PR page's card-with-header-divider so the sections read consistently. +// Header is omitted when neither title nor trailing is provided. export function PRSection({ title, trailing, children }: Props) { + const showHeader = Boolean(title) || trailing != null return ( - - {title} - {trailing ? {trailing} : null} - + {showHeader ? ( + + {title ? {title} : null} + {trailing ? {trailing} : null} + + ) : null} {children} ) diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index bf083303132..8a399babe78 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -16,11 +16,21 @@ type Props = { details: GitHubWorkItemDetails | null // Inline title-edit action; the pencil affordance only shows when the PR is editable. titleAction: MobilePrTitleAction + // Hub chrome already surfaces open-on-web; hide the duplicate icon in that case. + showOpenOnWeb?: boolean + // When true, render without section chrome so identity can share a card with actions. + bare?: boolean } -// Header: state badge (incl. draft — display-only), title, author, head->base. -// The title is inline-editable on an open/draft PR (desktop parity). -export function PRSidebarHeader({ pr, details, titleAction }: Props) { +// Compact identity: state + # + author on one meta row, title, head→base. +// # lives only in the meta row (not also after the title) to avoid repetition. +export function PRSidebarHeader({ + pr, + details, + titleAction, + showOpenOnWeb = true, + bare = false +}: Props) { const item = details?.item const badge = prStateBadge(pr.state) const badgeColor = statusColor(badge.token) @@ -29,14 +39,12 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) { const baseRef = item?.baseRefName ?? null const headRef = item?.branchName ?? null const editable = canEditPRTitle(pr.state) - // Badge, #number, and the flush-right external-link control all open pr.url - // (canonical host web URL) in the phone browser. const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined - return ( - - - + const body = ( + <> + + {badge.label} - {openPr ? ( - [styles.iconButton, pressed && { opacity: 0.6 }]} - > - - - ) : null} + + #{pr.number} + + {author ? · {author} : null} - - {author ? by {author} : null} - {baseRef && headRef ? ( - // head -> base reads in merge direction (desktop ChecksPanel parity). - - {headRef} - - {baseRef} - + {showOpenOnWeb && openPr ? ( + [styles.iconButton, pressed && { opacity: 0.6 }]} + > + + ) : null} + + {baseRef && headRef ? ( + + + {headRef} + + + + {baseRef} + + + ) : null} + + ) + + if (bare) { + return {body} + } + return ( + + {body} ) } function PRTitle({ title, - number, editable, - openPr, titleAction }: { title: string - number: number editable: boolean - openPr: (() => void) | undefined titleAction: MobilePrTitleAction }) { const [editing, setEditing] = useState(false) @@ -119,7 +137,7 @@ function PRTitle({ if (editing) { return ( - + - - {title}{' '} - - #{number} - - + {title} {editable ? ( diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx index 97d89a34b3e..8a3a161f358 100644 --- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx +++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native' +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubAssignableUser } from '../../../../src/shared/types' @@ -26,9 +26,10 @@ type LoadState = | { status: 'error'; message: string } | { status: 'loaded'; users: GitHubAssignableUser[] } -// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new -// primitive). Seeded with already-requested/latest reviewers + author at the top. -// Optimistic add/remove via onToggle. +// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList): +// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in +// that ScrollView throws and can leave the Reviewers section blank. +// Seeded reviewers + author sort first for quick un-request. export function ReviewerPickerDrawer({ visible, onClose, @@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({ } let cancelled = false setLoad({ status: 'loading' }) - void fetchAssignableUsers(client, worktreeId).then((outcome) => { - if (cancelled) { - return - } - setLoad( - outcome.ok - ? { status: 'loaded', users: outcome.result } - : { status: 'error', message: outcome.error } - ) - }) + void fetchAssignableUsers(client, worktreeId) + .then((outcome) => { + if (cancelled) { + return + } + setLoad( + outcome.ok + ? { status: 'loaded', users: outcome.result } + : { status: 'error', message: outcome.error } + ) + }) + .catch(() => { + if (!cancelled) { + setLoad({ status: 'error', message: 'Failed to load people' }) + } + }) return () => { cancelled = true } @@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({ No matching people ) : ( - u.login} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => { + + {ordered.map((item) => { const requested = isRequested(item.login) return ( onToggle(item.login)} accessibilityRole="button" @@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({ ) - }} - /> + })} + )} ) diff --git a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts index 1c66ed7a48b..edf308350e4 100644 --- a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts +++ b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts @@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ section: { backgroundColor: colors.bgPanel, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - overflow: 'hidden' + borderBottomColor: colors.borderSubtle }, // Section header row: title + optional trailing control, divided from the body // by a hairline border (desktop `h-10 border-b px-3`). @@ -55,14 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: 13, fontWeight: '600' }, - // Header section: state badge, title, author, base<-head branches. - // Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it). - badgeRow: { + // Identity + actions share one section card (hub redesign): no per-block chrome. + identityBlock: { + gap: spacing.sm + }, + // State badge + #number + author on one row; open-on-web flush right when shown. + metaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, + metaLeft: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs + }, badge: { alignSelf: 'flex-start', paddingHorizontal: spacing.sm, @@ -76,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontWeight: '700' }, prTitle: { + flex: 1, color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '700', @@ -85,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // #number in the meta row — stronger than author so the identity scans first. + prMetaStrong: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, // Title row: tappable area pairing the title with a trailing edit affordance. titleRow: { flexDirection: 'row', @@ -104,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({ gap: spacing.xs }, branchPill: { + flexShrink: 1, color: colors.textPrimary, fontSize: typography.metaSize, fontFamily: typography.monoFamily, - backgroundColor: colors.bgPanel, + backgroundColor: colors.bgRaised, paddingHorizontal: spacing.xs, paddingVertical: 2, borderRadius: radii.button @@ -146,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Loading / empty status row inside Reviewers (and similar) section bodies. + reviewersStatus: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, summaryLabel: { fontSize: typography.bodySize, fontWeight: '700' @@ -274,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: typography.bodySize, marginBottom: spacing.sm }, + // No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we + // never nest a VirtualizedList inside the PR page ScrollView. pickerList: { - maxHeight: 320 + gap: 0 }, pickerRow: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-actions-styles.ts b/mobile/src/components/pr-sidebar/pr-actions-styles.ts index 865516e3468..6d89e75bf98 100644 --- a/mobile/src/components/pr-sidebar/pr-actions-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-actions-styles.ts @@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // line). Split out of mobile-pr-sidebar-styles to keep that file under the // 300-line cap. export const prActionsStyles = StyleSheet.create({ + // Bare block when identity + actions share one section card. + actionsBlock: { + gap: spacing.sm + }, + // Close/Reopen + Unlink share a row so secondary actions don't stack full-width. + secondaryRow: { + flexDirection: 'row', + alignItems: 'stretch', + gap: spacing.sm + }, + secondaryButton: { + flex: 1 + }, // Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add). actionButton: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts index 3d44a9e0cc2..e6cb4966ca2 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts @@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // match the PR comment timeline; split out to keep PRCommentComposer focused. export const prCommentComposerStyles = StyleSheet.create({ container: { - gap: spacing.sm + // Input → Cancel/Save needs clear separation (title edit was flush without this). + gap: spacing.md }, input: { minHeight: 64, diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 1adb72ec8b8..56904e482cd 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => { expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }]) }) + it('accepts raw gh latestReviews author.login nesting', () => { + const parsed = readWorkItemDetails({ + item: { + id: 'n', + type: 'pr', + number: 1, + state: 'open', + latestReviews: [ + { author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' } + ] + } + }) + expect(parsed?.item.latestReviews).toEqual([ + { login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' } + ]) + }) + it('returns null when item is unparseable', () => { expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull() expect(readWorkItemDetails(null)).toBeNull() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index e7dcf9313ba..6ba8ccd6954 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null if (!isRecord(value)) { return null } - const login = readString(value.login) + // Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json` + // keeps nested `author.login` — accept both so mobile never drops reviewers. + const nestedAuthor = isRecord(value.author) ? value.author : null + const login = + readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined) if (login === undefined) { return null } + const avatarUrl = + readString(value.avatarUrl) ?? + (nestedAuthor + ? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null) + : null) return { login, state: readString(value.state) ?? null, - avatarUrl: readString(value.avatarUrl) ?? null + avatarUrl } } diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts index 4ce32a7cec7..1b4d9a1b50b 100644 --- a/mobile/src/session/mobile-pr-sidebar-state.ts +++ b/mobile/src/session/mobile-pr-sidebar-state.ts @@ -157,6 +157,7 @@ export function resolvePrSidebarDetailsAfterPhase2(args: { } // Placeholder details so Description/Comments leave the spinner after a failed phase 2. +// The synthetic id is the only id shape we mint — real GitHub node ids never match. export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { return { item: { @@ -175,6 +176,20 @@ export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { } } +// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews). +export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean { + return details.item.id === `pr-${details.item.number}` +} + +// Phase-2 still needs a real payload: null (in flight / not started) or a +// synthetic placeholder from a failed fetch. Real details (including empty body) +// do not qualify — ensure/retry must not re-pull those. +export function prSidebarDetailsNeedFetch( + details: GitHubWorkItemDetails | null | undefined +): boolean { + return details == null || isPrSidebarDetailsPlaceholder(details) +} + // 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 { diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index 72bde872409..d879454c50c 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle expect(engine.error).toBe('socket hung up') expect(engine.busy).toBeNull() }) + + it('surfaces a refetch throw as error without rejecting the action promise', async () => { + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: async () => { + throw new Error('refresh failed') + }, + onChange: () => {} + }) + await expect(engine.merge()).resolves.toBeUndefined() + expect(engine.error).toBe('refresh failed') + expect(engine.busy).toBeNull() + }) + + it('does not notify on no-op setError(null) at action start', async () => { + const onChange = vi.fn() + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: () => {}, + onChange + }) + // Idle: error is already null. Action start must only notify for busy, not a + // redundant clearError path. + onChange.mockClear() + const p = engine.merge() + // First notify is setBusy only (setError(null) no-ops). + expect(onChange).toHaveBeenCalledTimes(1) + await p + }) }) describe('PrActionsEngine — PR identity changes', () => { diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index a647b62dfe9..7c34954534d 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -129,7 +129,12 @@ export class PrActionsEngine { return f } + // Why: action start pairs setBusy + setError(null); skip notify when unchanged + // so we don't force a full PR panel re-render for free. private setBusy(key: PrActionBusyKey | null): void { + if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) { + return + } this.busy = key this.cfg.onChange() } @@ -143,6 +148,9 @@ export class PrActionsEngine { } private setError(message: string | null): void { + if (this.error === message) { + return + } this.error = message this.cfg.onChange() } @@ -180,7 +188,14 @@ export class PrActionsEngine { } if (outcome.ok) { handlers.onSuccess() - await this.cfg.refetch() + // Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox. + try { + await this.cfg.refetch() + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.') + } + } return } // Both failure classes clear optimism to authoritative; only the message @@ -205,6 +220,10 @@ export class PrActionsEngine { prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.') + } } finally { this.clearBusyIfOwned(identity, { kind: 'merge' }) } 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 de77433fcb6..f402f7b58ad 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts @@ -5,6 +5,8 @@ import type { GitHubPrReadOutcome } from './github-pr-rpc' import { classifyPrSidebarFailure, emptyPrSidebarDetails, + isPrSidebarDetailsPlaceholder, + prSidebarDetailsNeedFetch, loadPrSidebarData, loadPrSidebarDetails, resolvePrSidebarDetailsAfterPhase2, @@ -240,6 +242,29 @@ describe('resolvePrSidebarDetailsAfterPhase2', () => { expect(empty.comments).toEqual([]) expect(empty.item.number).toBe(7) expect(empty.item.type).toBe('pr') + expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true) + }) +}) + +describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => { + it('detects the synthetic hyphen id, not the host pr:n shape', () => { + const placeholder = emptyPrSidebarDetails(PR) + expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true) + expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false) + // Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`). + expect( + isPrSidebarDetailsPlaceholder({ + ...DETAILS, + item: { ...DETAILS.item, id: `pr:${PR.number}` } + }) + ).toBe(false) + }) + + it('needs fetch for null and placeholders, not real details', () => { + expect(prSidebarDetailsNeedFetch(null)).toBe(true) + expect(prSidebarDetailsNeedFetch(undefined)).toBe(true) + expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true) + expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index 415a7581b7d..752b2ecb3b8 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -11,6 +11,7 @@ import { import { loadPrSidebarData, loadPrSidebarDetails, + prSidebarDetailsNeedFetch, resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, shouldSoftRefreshPrSidebarOnHeadChange, @@ -103,12 +104,21 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { if (!probeReady || !client) { return } - void fetchGithubRepoSlug(client, worktreeId).then((outcome) => { - if (!cancelled) { - setIsGithubRepo(outcome.ok && outcome.result !== null) - setRepoProbeLoaded(true) - } - }) + void fetchGithubRepoSlug(client, worktreeId) + .then((outcome) => { + if (!cancelled) { + setIsGithubRepo(outcome.ok && outcome.result !== null) + setRepoProbeLoaded(true) + } + }) + .catch(() => { + // Why: sendGithubPrRead already normalizes throws, but a cancelled + // unmount + any unexpected rejection must not surface as LogBox. + if (!cancelled) { + setIsGithubRepo(false) + setRepoProbeLoaded(true) + } + }) return () => { cancelled = true } @@ -228,9 +238,12 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { // 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. + // Retries synthetic placeholders too: a failed phase-2 installs non-null empty + // details so Description/Comments leave the spinner, and without this ensure + // would never re-fetch on tab re-open. const ensurePrSidebarDetails = useCallback(async () => { const current = stateRef.current - if (current.kind !== 'ready' || current.data.details != null) { + if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) { return } const deps = buildDeps() @@ -303,7 +316,8 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { (state.kind !== 'ready' && state.kind !== 'loading') ) { void load({ includeDetails: true }) - } else if (state.kind === 'ready' && state.data.details == null) { + } else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) { + // Retries phase-2 placeholders too (failed details load, not only null). void ensurePrSidebarDetails() } }, [identity, state, load, ensurePrSidebarDetails]) diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 332f62312f1..25656aae2bd 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -16,6 +16,7 @@ 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 { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state' import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' import { openMobilePrUrl } from '../components/MobilePrComposeSheet' @@ -177,8 +178,11 @@ export function MobileSourceControlPanel({ // 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). + // Placeholder details (failed phase 2) also count as missing so reopening the + // PR tab retries instead of leaving empty Description/Comments forever. const prDetailsMissingFor = - prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null + prController.prSidebarState.kind === 'ready' && + prSidebarDetailsNeedFetch(prController.prSidebarState.data.details) ? prController.prSidebarState.data.pr.number : null @@ -299,6 +303,8 @@ export function MobileSourceControlPanel({ // Controller + chip state still live for instant re-open without a full cold start. const showPrBody = ready && activeTab === 'pr' const conflictOperation = status?.conflictOperation ?? null + // Git status always reports a conflictOperation enum; 'unknown' means none. + const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown' const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation) return ( @@ -313,7 +319,11 @@ export function MobileSourceControlPanel({ - {ready ? ( + {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR + tab they duplicate the ready PR body (#, state, checks rollup, branch + trajectory), so hide the whole card there and let the PR panel own it — + unless a merge/rebase conflict is active, which only this card can abort. */} + {ready && (activeTab !== 'pr' || hasActiveConflict) ? ( { expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true) expect(labels).toContain('Rebase onto base') expect(labels).toContain('Switch branch') - expect(labels).toContain('History') + expect(labels).toContain('Commits') expect(labels).toContain('Create PR') }) @@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => { const handlers = noopHandlers() const actions = buildMobileSourceControlActions(args({ handlers })) action(actions, 'Switch branch')?.onPress() - action(actions, 'History')?.onPress() + action(actions, 'Commits')?.onPress() expect(handlers.checkout).toHaveBeenCalled() expect(handlers.history).toHaveBeenCalled() }) diff --git a/mobile/src/source-control/mobile-source-control-actions.ts b/mobile/src/source-control/mobile-source-control-actions.ts index 0e504c5a2b1..b06af8a6aab 100644 --- a/mobile/src/source-control/mobile-source-control-actions.ts +++ b/mobile/src/source-control/mobile-source-control-actions.ts @@ -217,7 +217,7 @@ export function buildMobileSourceControlActions( onPress: handlers.checkout }, { - label: 'History', + label: 'Commits', iconKey: 'history', disabled: busy, onPress: handlers.history diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts index 683fa3a1dcc..10d126177e8 100644 --- a/mobile/src/source-control/mobile-source-control-hub-styles.ts +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -4,29 +4,29 @@ 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({ + // Full-width tab bar under the hub header. Edge-to-edge, no inset track and no + // inner padding — segments share height evenly so the control doesn't float in a + // pill frame with gaps above/below the active cell. 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 + alignItems: 'stretch', + width: '100%', + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle }, segment: { flex: 1, - minHeight: 34, - borderRadius: radii.button, + minHeight: 40, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: spacing.xs + paddingHorizontal: spacing.xs, + // Reserved so active underline doesn't change layout height. + borderBottomWidth: 2, + borderBottomColor: 'transparent' }, segmentActive: { - backgroundColor: colors.bgPanel, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle + borderBottomColor: colors.textPrimary }, segmentPressed: { opacity: 0.7 diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts index e243032a58c..3a0a48406ce 100644 --- a/mobile/src/source-control/mobile-source-control-hub-tab.ts +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [ export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = { changes: 'Changes', pr: 'Pull Request', - history: 'History' + history: 'Commits' } // Normalize a route param (possibly an array from expo-router, possibly unknown) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3a308a3adfb..6e4005421a4 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -848,6 +848,43 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(JSON.parse(stdout) as Record) } +// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON +// `gh pr view` path fails, still pull review fields from gh so mobile/desktop +// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty. +const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' + +async function fetchPullRequestReviewFields( + number: number, + ownerRepo: OwnerRepo | null, + ghOptions: GhExecOptions +): Promise> { + try { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_REVIEW_JSON_FIELDS + ] + : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS] + const { stdout } = await ghExecFileAsync(args, ghOptions) + const item = JSON.parse(stdout) as Record + return { + ...(item.reviewRequests !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}) + } + } catch { + return {} + } +} + async function fetchPullRequestWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, @@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem( ) const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) + // Why: merge-metadata GraphQL is best-effort. A failure here must not fall + // through to the REST path below — that path drops latestReviews and blanks + // the mobile/desktop reviewer list for bots that only left a review. const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) - return { - ...mapped, - mergeQueueRequired: mergeMetadata.mergeQueueRequired, - ...(mergeMetadata.autoMergeAllowed !== null - ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } - : {}), - ...(mergeMetadata.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) + try { + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + return { + ...mapped, + mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(mergeMetadata.autoMergeAllowed !== null + ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } + : {}), + ...(mergeMetadata.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } + } catch { + return mapped } } catch { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], ghOptions ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + const mapped = mapPullRequestWorkItem( + JSON.parse(stdout) as Record, + ownerRepo + ) + const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions) + return { ...mapped, ...reviewFields } } }