From d4ae7b2392de7ca7f31dd5c4e0e0c68b724a80e5 Mon Sep 17 00:00:00 2001 From: Noah Khomer <108771853+noahkhomer18@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:49:54 -0500 Subject: [PATCH 01/65] fix: match workspace tab divider line colors with rest of UI (#5987) Fixes #5906 The divider line on the workspace tab strip used full opacity border-border, while other divider lines in the UI use varied opacities. Changed to border-border/70 to match the standard divider opacity used throughout the application. Signed-off-by: Noah Khomer <108771853+noahkhomer18@users.noreply.github.com> --- src/renderer/src/components/tab-bar/TabBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index a6ecdc5917b..0455c4fb64b 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -1045,7 +1045,7 @@ function TabBarInner({ // a different box than the tab's own `border-t`, producing a // heavier-looking L-corner at the leftmost tab when inactive. className={[ - 'terminal-tab-strip flex h-full min-w-0 max-w-full flex-1 items-stretch overflow-x-auto overflow-y-hidden border-r border-border', + 'terminal-tab-strip flex h-full min-w-0 max-w-full flex-1 items-stretch overflow-x-auto overflow-y-hidden border-r border-border/70', getTabStripScrollMaskClassName(tabStripOverflowState) ] .filter(Boolean) From 1fde7f553c6fc6a56edd538ba85583ac7699c960 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 23 Jun 2026 00:04:01 +0100 Subject: [PATCH 02/65] fix(agent-hooks): wrap Windows hook commands in cmd.exe to survive spaces in profile path (#6078) (#6083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): wrap Windows hook command in cmd.exe to survive spaces in profile path (#6078) Windows splits raw hook commands on whitespace, so a user profile path like `C:\Users\Jane Doe` made Codex hooks exit with code 1. Add a wrapWindowsHookCommand helper that invokes the .cmd through `cmd.exe /d /c call "..."` and use it in getManagedCommand. * fix(agent-hooks): wrap Windows hook command in cmd.exe for all agents with raw .cmd path (#6078) Apply the wrapWindowsHookCommand helper to cursor, command-code, gemini, grok, and droid, which shared the same raw-scriptPath-on-Windows pattern as codex. A user profile path with a space (e.g. `C:\Users\Jane Doe`) used to split at the space and fail with exit code 1. Agents that already handle spaces correctly are left untouched: - claude/openclaude (Git Bash + forward slashes) - copilot (PowerShell with quoted path) - kimi (Git Bash + forward slashes) - antigravity (event-specific wrapper .cmd files) - devin (already wraps via `cmd /d /s /c ""...""`) Each fixed agent gets a Windows-only test asserting the cmd.exe wrapping survives spaces in the profile path. * fix(claude): wrap Windows hook command in cmd.exe to survive spaces in profile path (#6078) Claude Code runs hooks through Git Bash on Windows. The previous forward-slash trick only works when the path has no spaces — Git Bash splits `C:/Users/Jane Doe/...` at the space and tries to execute `C:/Users/Jane` as a command. Use wrapWindowsHookCommand so the .cmd is invoked through `cmd.exe /d /c call "..."`, which Git Bash treats as one argument. Applies to both Claude and OpenClaude (shared getManagedCommand). * Harden Windows agent hook launcher --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/agent-hooks/installer-utils.test.ts | 57 ++++++++++++++++++ src/main/agent-hooks/installer-utils.ts | 31 +++++++++- src/main/claude/hook-service.test.ts | 51 +++++++++++++++- src/main/claude/hook-settings.ts | 9 ++- src/main/codex/hook-service.test.ts | 62 +++++++++++++++++--- src/main/codex/hook-service.ts | 5 +- src/main/command-code/hook-service.test.ts | 41 +++++++++++-- src/main/command-code/hook-service.ts | 5 +- src/main/cursor/hook-service.test.ts | 45 +++++++++++++- src/main/cursor/hook-service.ts | 5 +- src/main/droid/hook-service.test.ts | 39 +++++++++++- src/main/droid/hook-service.ts | 10 +++- src/main/gemini/hook-service.test.ts | 48 +++++++++++++-- src/main/gemini/hook-service.ts | 5 +- src/main/grok/hook-service.test.ts | 47 ++++++++++++++- src/main/grok/hook-service.ts | 5 +- 16 files changed, 423 insertions(+), 42 deletions(-) diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index f7ca2d3ea40..43c08542c60 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -20,6 +20,7 @@ import { hookDefinitionHasManagedCommand, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeManagedScript, writeHooksJson, type HooksConfig @@ -167,6 +168,11 @@ describe('createManagedCommandMatcher', () => { ).toBe(true) }) + it('matches encoded Windows launcher commands by decoding their script path', () => { + const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') + expect(match(command)).toBe(true) + }) + it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => { // Why: install() must sweep old per-userData commands when migrating to // the shared ~/.orca script path, or stale launchers keep failing. @@ -332,6 +338,57 @@ describe('wrapPosixHookCommand', () => { ) }) +describe('wrapWindowsHookCommand', () => { + function decodeWindowsHookCommand(command: string): string { + const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] + expect(encodedCommand).toBeTruthy() + return Buffer.from(encodedCommand!, 'base64').toString('utf16le') + } + + it('invokes the .cmd through an encoded PowerShell command', () => { + const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') + expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/) + expect(decodeWindowsHookCommand(command)).toBe( + "& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + // Why: a user profile path like `C:\Users\Jane Doe` is the regression from + // #6078 — the raw path used to be split at the space. The wrapper must keep + // the whole path inside the encoded command so shells do not split it. + it('preserves spaces in the script path (user profile with space case)', () => { + const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd') + expect(decodeWindowsHookCommand(cmd)).toBe( + "& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + it('keeps cmd.exe percent expansion and caret escapes out of the command line', () => { + const cmd = wrapWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd') + expect(cmd).not.toContain('%ORCA_TEST%') + expect(cmd).not.toContain('^') + expect(decodeWindowsHookCommand(cmd)).toBe( + "& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + it.skipIf(process.platform !== 'win32')( + 'executes a script path containing a cmd.exe caret literally', + () => { + const scriptDir = join(tmpDir, 'home with ^ caret', '.orca', 'agent-hooks') + mkdirSync(scriptDir, { recursive: true }) + const scriptPath = join(scriptDir, 'codex-hook.cmd') + writeFileSync(scriptPath, '@echo off\r\nexit /b 7\r\n', 'utf-8') + + const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsHookCommand(scriptPath)], { + env: { ...process.env, ORCA_WRAP_TEST: 'expanded' } + }) + + expect(result.status).toBe(7) + } + ) +}) + describe('buildWindowsAgentHookPostCommand', () => { it('forces UTF-8 for redirected hook stdin and POST bodies', () => { const command = buildWindowsAgentHookPostCommand('codex') diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 07f8e834d9d..a533fb0ed1a 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -75,11 +75,25 @@ export function createManagedCommandMatcher( if (!command) { return false } - const normalizedCommand = command.replaceAll('\\', '/') + const decodedCommand = decodePowerShellEncodedCommand(command) + const searchText = decodedCommand ? `${command}\n${decodedCommand}` : command + const normalizedCommand = searchText.replaceAll('\\', '/') return needles.some((needle) => normalizedCommand.includes(needle)) } } +function decodePowerShellEncodedCommand(command: string): string | null { + const match = command.match(/\s-EncodedCommand\s+(\S+)/i) + if (!match) { + return null + } + try { + return Buffer.from(match[1], 'base64').toString('utf16le') + } catch { + return null + } +} + // Why: prod, dev, and parallel Orca instances must write the same managed // settings entry instead of racing between per-userData script paths. export function getSharedManagedScriptPath(scriptFileName: string): string { @@ -106,6 +120,21 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record { ) expect(legacyCommands).toContain('/usr/local/bin/user-hook') expect( - legacyCommands.some((command: string) => command.includes(CLAUDE_SCRIPT_FILE_NAME)) + legacyCommands.some((command: string) => + process.platform === 'win32' + ? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command.includes(CLAUDE_SCRIPT_FILE_NAME) + ) ).toBe(true) expect( legacyCommands.some((command: string) => command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) - expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME) + expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(CLAUDE_SCRIPT_FILE_NAME) + ) expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('DEVIN_PROJECT_DIR') @@ -177,6 +185,36 @@ describe('ClaudeHookService.install', () => { rmSync(tmpHome, { recursive: true, force: true }) } }) + + // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path + // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed + // command must use an encoded launcher so Git Bash/cmd.exe never splits or + // expands the raw path before invoking the managed .cmd. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse( + readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') + ) as { hooks: Record } + + for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { + const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + ) }) describe('ClaudeHookService.installRemote', () => { @@ -287,7 +325,14 @@ describe('OpenClaudeHookService-compatible install', () => { const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8')) for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string - expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME) + if (process.platform === 'win32') { + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } else { + expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME) + expect(command).toMatch(/^if \[ -x /) + } } expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index f6a7a2a0f73..fc94d6e2c6a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -5,6 +5,7 @@ import { getSharedManagedScriptPath, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' @@ -74,9 +75,11 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S export function getManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: Claude Code runs hooks through Git Bash on Windows; forward slashes - // survive that shell layer while native Windows APIs still accept them. - return scriptPath.replaceAll('\\', '/') + // Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes + // alone don't survive a path with spaces — bash splits at the space and + // tries to execute `C:/Users/Jorge` as a command. Wrapping in + // `cmd.exe /d /c call "..."` keeps the path as one argument. #6078. + return wrapWindowsHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index dc0dc060a62..94f365a213a 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from 'os' import type * as Os from 'os' import { join } from 'path' -import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ @@ -39,6 +39,11 @@ import { CodexHookService } from './hook-service' let tmpHome: string let userDataDir: string + +function isCodexManagedCommand(command: string | undefined): boolean { + const scriptFileName = process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + return createManagedCommandMatcher(scriptFileName)(command) +} let previousUserDataPath: string | undefined beforeEach(() => { @@ -118,8 +123,9 @@ describe('CodexHookService', () => { 'UserPromptSubmit' ].sort() ) - expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks') - expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect( + isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) + ).toBe(true) const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(trustConfig).toContain('model = "gpt-5.2-codex"') @@ -127,6 +133,40 @@ describe('CodexHookService', () => { expect(trustConfig).toContain(':permission_request:0:0') }) + // Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to + // be written verbatim as the hook command, so Codex split it at the space and + // the hook exited with code 1. The managed command uses an encoded launcher + // so the path never appears raw on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command in an encoded launcher when the profile path contains a space (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + const systemCodexHome = join(spaceHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + + const status = new CodexHookService().install() + expect(status.state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const hooksConfig = JSON.parse( + readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') @@ -177,12 +217,12 @@ describe('CodexHookService', () => { ).toBe(true) expect( devHooks.hooks.Stop?.some((definition) => - definition.hooks?.[0]?.command?.includes('codex-hook') + isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) expect( prodHooks.hooks.Stop?.some((definition) => - definition.hooks?.[0]?.command?.includes('codex-hook') + isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks) @@ -296,7 +336,9 @@ describe('CodexHookService', () => { hooks: Record } - expect(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect(isCodexManagedCommand(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command)).toBe( + true + ) expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe( 'slow-user-post-tool-hook' ) @@ -430,7 +472,7 @@ describe('CodexHookService', () => { ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) expect(runtimeHooks.hooks.PreCompact).toBeUndefined() for (const command of pluginCommands) { expect(runtimeHooksText).not.toContain(command) @@ -953,8 +995,10 @@ describe('CodexHookService', () => { (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) - expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) + expect( + isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) + ).toBe(true) const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain('[features]\nhooks = true') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index f53a180a15f..99638a787bc 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -11,6 +11,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookCommandConfig, @@ -111,7 +112,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getSystemConfigPath(): string { diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index c6b8bf1b8b9..7dd2abcb9a7 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -48,15 +48,46 @@ describe('CommandCodeHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') expect(config.hooks.Stop[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) - if (process.platform === 'win32') { - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook.cmd') - } else { + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /command-code-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } + if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca command-code home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new CommandCodeHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.commandcode', 'settings.json'), 'utf8') + ) as { hooks: Record } + + const command = config.hooks.PreToolUse[0].hooks[0].command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('installs a hook script that can recover the endpoint when Command Code strips token env', () => { new CommandCodeHookService().install() diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 435344bd97c..a5a34a30512 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -10,6 +10,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -45,7 +46,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 6a1cee03498..4f310d595b8 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -58,8 +58,14 @@ describe('CursorHookService', () => { expect(Object.keys(config.hooks).sort()).toEqual([...CURSOR_EVENTS].sort()) for (const eventName of CURSOR_EVENTS) { const definition = config.hooks[eventName]?.[0] - expect(definition?.command).toContain('cursor-hook') - expect(definition?.command).toContain(join(homeDir, '.orca')) + expect(definition?.command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /cursor-hook/ + ) + if (process.platform !== 'win32') { + expect(definition?.command).toContain(join(homeDir, '.orca')) + } expect(definition?.hooks).toBeUndefined() } @@ -75,6 +81,35 @@ describe('CursorHookService', () => { } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca cursor home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new CursorHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.cursor', 'hooks.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['beforeSubmitPrompt', 'stop']) { + const command = config.hooks[eventName]?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('preserves user-authored Cursor hook entries and removes stale managed entries', () => { const configPath = join(homeDir, '.cursor', 'hooks.json') mkdirSync(dirname(configPath), { recursive: true }) @@ -107,7 +142,11 @@ describe('CursorHookService', () => { const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command) expect(promptCommands).toContain('/usr/local/bin/user-hook') expect( - promptCommands.filter((command) => command?.includes(CURSOR_SCRIPT_FILE_NAME)) + promptCommands.filter((command) => + process.platform === 'win32' + ? command?.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command?.includes(CURSOR_SCRIPT_FILE_NAME) + ) ).toHaveLength(1) expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([ '/usr/local/bin/retired-user-hook' diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 3b74d2538d3..fb57ce1b8fd 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -59,7 +60,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 5608cc10dd8..65ac1538302 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -70,11 +70,46 @@ describe('DroidHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PermissionRequest[0].matcher).toBe('*') expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('droid-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /droid-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir) }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca droid home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new DroidHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.factory', 'settings.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('reports partial when Factory has hooks disabled globally', () => { const configPath = join(homeDir, '.factory', 'settings.json') mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 9b444d0d11a..f8e07cec3ab 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -8,6 +8,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -54,9 +55,12 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: Factory invokes the .cmd directly via cmd.exe (no bash), so native - // backslashes are correct on Windows. Matches the codex/cursor pattern. - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + // Why: Factory invokes the .cmd via cmd.exe, but the raw path still splits at + // whitespace when the user profile contains a space (e.g. `C:\Users\Jane Doe`). + // The shared Windows wrapper keeps the path out of cmd.exe's raw command line. #6078. + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 7490a1b8434..587762de95a 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -100,12 +100,46 @@ describe('GeminiHookService', () => { expect(config.hooks.PreToolUse).toBeUndefined() expect(config.hooks.BeforeAgent).toHaveLength(2) expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') - expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath) + const managedCommandPattern = + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern) }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca gemini home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new GeminiHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.gemini', 'settings.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + homedirMock.mockReturnValue(homeDir) + } + } + ) + it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => { const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' const staleManagedHookPath = @@ -147,6 +181,10 @@ describe('GeminiHookService', () => { expect(status.state).toBe('installed') expect(preToolCommands).toEqual(['echo user-authored']) - expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName) + expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + ) }) }) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index ac687f6c6be..889e7a467f8 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -43,7 +44,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index fd5467b66b3..52d46fbcf19 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -59,8 +59,14 @@ describe('GrokHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') expect(config.hooks.Notification[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /grok-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } const script = readFileSync( join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME), @@ -74,6 +80,35 @@ describe('GrokHookService', () => { } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca grok home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new GrokHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('preserves user-authored hook entries in the Orca Grok config file', () => { const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) @@ -99,6 +134,12 @@ describe('GrokHookService', () => { definition.hooks.map((hook) => hook.command) ) expect(commands).toContain('/usr/local/bin/user-hook') - expect(commands.some((command) => command.includes(GROK_SCRIPT_FILE_NAME))).toBe(true) + expect( + commands.some((command) => + process.platform === 'win32' + ? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command.includes(GROK_SCRIPT_FILE_NAME) + ) + ).toBe(true) }) }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index eefc6dae5fa..e8a81952a59 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -55,7 +56,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { From bcd52811602266d37c0a86a226e7361b93ff1a84 Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Mon, 22 Jun 2026 17:04:07 -0600 Subject: [PATCH 03/65] fix(terminal): gate Windows ConPTY backend selection on execution host (serve panes) Closes #6022 --- .../use-terminal-pane-lifecycle.ts | 4 +- .../windows-pty-compatibility.test.ts | 48 ++++++++++++++++--- .../pane-manager/windows-pty-compatibility.ts | 17 ++++++- 3 files changed, 60 insertions(+), 9 deletions(-) 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 a1a92971924..47efa7a455d 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 @@ -63,6 +63,7 @@ import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { getConnectionId } from '@/lib/connection-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -1049,7 +1050,8 @@ export function useTerminalPaneLifecycle({ osRelease: platformInfo?.osRelease, connectionId: getConnectionId(worktreeId), cwd: startupCwd, - shellOverride: currentTab?.shellOverride + shellOverride: currentTab?.shellOverride, + executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId) }) return { ...windowsPtyCompatibilityOptions, diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index b6789ba992f..249e5dda8b6 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -13,7 +13,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({ windowsPty: { backend: 'conpty', buildNumber: 26100 } @@ -27,7 +28,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: 'bad-release', connectionId: null, cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({ windowsPty: { backend: 'conpty' } @@ -41,7 +43,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: 'ssh-1', cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({}) }) @@ -59,7 +62,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd, - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({}) } @@ -72,7 +76,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd: 'C:\\repo', - shellOverride: 'C:\\Windows\\System32\\wsl.exe' + shellOverride: 'C:\\Windows\\System32\\wsl.exe', + executionHostId: 'local' }) ).toEqual({}) }) @@ -84,7 +89,38 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '23.0.0', connectionId: null, cwd: '/repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' + }) + ).toEqual({}) + }) + + it('does NOT return ConPTY options for a serve/remote-runtime pane even when the raw Windows heuristic matches', () => { + // Regression: a serve pane on a Windows client has no SSH connectionId and a + // Linux cwd, so the raw heuristic matches; the execution-host gate must still + // exclude it so a remote Linux PTY is not given the native-Windows ConPTY backend. + const serveContext = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: '/home/me/workspaces/repo', + shellOverride: null + } as const + expect(isLocalNativeWindowsPty(serveContext)).toBe(true) + expect( + buildWindowsPtyCompatibilityOptions({ ...serveContext, executionHostId: 'runtime:my-serve' }) + ).toEqual({}) + }) + + it('does NOT return ConPTY options for an SSH-runtime pane', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: null, + executionHostId: 'ssh:my-host' }) ).toEqual({}) }) diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 62f8362d194..5886dd84307 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -31,10 +31,18 @@ function parseWindowsBuildNumber(osRelease: string | null | undefined): number | return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined } +/** + * xterm options that select the native-Windows ConPTY backend, returned only for + * a genuine local Windows pane and `{}` otherwise. + * + * Why it requires `executionHostId`: a serve/remote-runtime pane on a Windows + * client looks local to the raw heuristic (no SSH `connectionId`, Linux `cwd`), + * so gating on the execution host keeps the ConPTY backend off remote PTYs. + */ export function buildWindowsPtyCompatibilityOptions( - context: WindowsPtyCompatibilityContext + context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId } ): Partial { - if (!isLocalNativeWindowsPty(context)) { + if (!isLocalNativeWindowsConpty(context)) { return {} } const buildNumber = parseWindowsBuildNumber(context.osRelease) @@ -46,6 +54,11 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** + * Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH + * connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a + * local pane from a serve pane, so callers gate it with `isLocalNativeWindowsConpty`. + */ export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { if (!isWindowsUserAgent(context.userAgent)) { return false From e46d4a9267d77f59d77f9a2317b055a13435864a Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 22 Jun 2026 16:27:33 -0700 Subject: [PATCH 04/65] Fix blank/unclosable mobile emulator tab in floating workspace (#6042) * Fix blank/unclosable mobile emulator tab in floating workspace FloatingTerminalPanel only handled terminal/browser/editor content types, so simulator tabs rendered no pane and routed close through closeFile (a no-op for simulator tabs). Treat simulator as its own content type: render EmulatorPane for the active simulator tab, wire activeSimulatorTabId into TabBar, and close via closeUnifiedTab. * Add floating Mobile Emulator tab E2E smoke test Adds a deterministic Electron/Playwright spec that seeds a simulator unified tab in the floating workspace, asserts the emulator pane renders, and closes it through the real tab-strip X. Adds stable data-emulator-pane selectors and a data-tab-close-button hook on the simulator tab chrome, plus a targeted package script. No live iOS Simulator or Orca Computer/AX dependency. * Exclude simulator tabs from floating Close All Files Close All Files filtered out only terminal/browser tabs, so after the simulator render/close fix it would also close the Mobile Emulator. Simulator tabs are not files; exclude contentType 'simulator' to match terminal/browser behavior, and add a regression test asserting Close All Files closes the editor tab but leaves the simulator open. * Keep floating simulator tabs mounted --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H --- package.json | 1 + .../components/emulator-pane/EmulatorPane.tsx | 5 +- .../emulator-unavailable-pane.tsx | 5 +- .../FloatingTerminalPanel.test.tsx | 112 ++++++++++++++++ .../FloatingTerminalPanel.tsx | 64 +++++++-- .../src/components/tab-bar/EditorFileTab.tsx | 3 + src/renderer/src/store/selectors.test.ts | 17 ++- src/renderer/src/store/selectors.ts | 4 + .../e2e/floating-mobile-emulator-tab.spec.ts | 125 ++++++++++++++++++ 9 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/floating-mobile-emulator-tab.spec.ts diff --git a/package.json b/package.json index 35762ce1269..14869d56ecf 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "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: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", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index ed07ec80440..b224e19ab1c 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -47,7 +47,10 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP }) return ( -
+
+

{translate( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 29ffe3ceb4d..8306c7b9102 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -52,6 +52,7 @@ type FloatingPanelStoreState = { closeTab: (tabId: string) => void closeBrowserTab: (tabId: string) => void closeFile: (fileId: string) => void + closeUnifiedTab: (tabId: string) => Tab | null markFileDirty: (fileId: string, dirty: boolean) => void activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void @@ -85,6 +86,7 @@ const mocks = vi.hoisted(() => ({ closeWebRuntimeSessionTab: vi.fn(), closeFile: vi.fn(), closeTab: vi.fn(), + closeUnifiedTab: vi.fn(), createBrowserTab: vi.fn(), createTab: vi.fn(), createWebRuntimeSessionBrowserTab: vi.fn(), @@ -177,6 +179,12 @@ vi.mock('@/components/browser-pane/BrowserPane', () => ({ } })) +vi.mock('@/components/emulator-pane/EmulatorPane', () => ({ + default: function EmulatorPane() { + return null + } +})) + vi.mock('@/components/editor/EditorPanel', () => ({ default: function EditorPanel() { return null @@ -406,6 +414,38 @@ function setFloatingEditorTabs(files: OpenFile[]): void { state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } } +function setFloatingSimulatorTab(): Tab { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const tab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab] } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab.id] } + return tab +} + function resetStore(tabs: TerminalTab[] = []): void { storeBox.state = { tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }, @@ -420,6 +460,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, + closeUnifiedTab: mocks.closeUnifiedTab, createTab: mocks.createTab, createBrowserTab: mocks.createBrowserTab, closeTab: mocks.closeTab, @@ -1678,6 +1719,77 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) + it('renders and closes simulator tabs in the floating workspace', async () => { + const tab = setFloatingSimulatorTab() + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + const emulatorPane = findByTypeName(element, 'EmulatorPane') + ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) + + expect(tabBar.props.activeTabType).toBe('simulator') + expect(tabBar.props.activeSimulatorTabId).toBe(tab.id) + expect(emulatorPane.props.tab).toBe(tab) + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) + expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.id) + }) + + it('keeps simulator tabs open when closing all files', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const file = makeFile({ id: 'file-a' }) + const editorTab: Tab = { + id: 'tab-file-a', + entityId: file.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'editor', + label: file.relativePath, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + state.openFiles = [file] + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab, simulatorTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: editorTab.id, + tabOrder: [editorTab.id, simulatorTab.id], + recentTabIds: [editorTab.id, simulatorTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab.id, simulatorTab.id] + } + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseAllFiles as () => void)() + + expect(mocks.closeFile).toHaveBeenCalledWith(file.id) + expect(mocks.closeUnifiedTab).not.toHaveBeenCalledWith(simulatorTab.id) + }) + it('routes floating terminal create and close through active web runtime sessions', async () => { const onOpenChange = vi.fn() setFloatingTabs([makeTab({ id: 'tab-1' })]) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index d4919c0b55f..f91eded8216 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -7,6 +7,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react' import { toast } from 'sonner' import BrowserPane from '@/components/browser-pane/BrowserPane' +import EmulatorPane from '@/components/emulator-pane/EmulatorPane' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import TabBar from '@/components/tab-bar/TabBar' @@ -184,6 +185,7 @@ export function FloatingTerminalPanel({ const closeTab = useAppStore((s) => s.closeTab) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) const closeFile = useAppStore((s) => s.closeFile) + const closeUnifiedTab = useAppStore((s) => s.closeUnifiedTab) const markFileDirty = useAppStore((s) => s.markFileDirty) const activateTab = useAppStore((s) => s.activateTab) const setActiveTab = useAppStore((s) => s.setActiveTab) @@ -277,11 +279,17 @@ export function FloatingTerminalPanel({ const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null const activeEditorUnifiedId = - activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser' + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' ? activeTab.id : null const activeEditorFileId = - activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser' + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' ? activeTab.entityId : null const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) @@ -331,7 +339,12 @@ export function FloatingTerminalPanel({ const editorItems = useMemo( () => groupTabs - .filter((tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser') + .filter( + (tab) => + tab.contentType !== 'terminal' && + tab.contentType !== 'browser' && + tab.contentType !== 'simulator' + ) .map((tab) => { const file = floatingFiles.find((candidate) => candidate.id === tab.entityId) return file ? { ...file, tabId: tab.id } : null @@ -339,11 +352,19 @@ export function FloatingTerminalPanel({ .filter((file): file is OpenFile & { tabId: string } => file !== null), [floatingFiles, groupTabs] ) - // Why: restored sessions can retain unified tabs whose backing terminal/file/browser - // records are gone; the empty landing should follow what the user can see. + const simulatorItems = useMemo( + () => groupTabs.filter((tab) => tab.contentType === 'simulator'), + [groupTabs] + ) + // Why: restored sessions can retain unified tabs whose backing records are + // gone; the empty landing should follow what the user can see. const hasVisibleFloatingTabs = - terminalItems.length > 0 || browserItems.length > 0 || editorItems.length > 0 - const visibleFloatingItemCount = terminalItems.length + browserItems.length + editorItems.length + terminalItems.length > 0 || + browserItems.length > 0 || + editorItems.length > 0 || + simulatorItems.length > 0 + const visibleFloatingItemCount = + terminalItems.length + browserItems.length + editorItems.length + simulatorItems.length const activeClosableTab = hasVisibleFloatingTabs ? activeTab : null const tabBarOrder = useMemo( () => @@ -366,7 +387,9 @@ export function FloatingTerminalPanel({ ? 'browser' : activeTab?.contentType === 'terminal' ? 'terminal' - : 'editor' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' useContextualTour('floating-workspace', open, 'floating_workspace_visible', { recordFeatureInteraction: tourInteractionSnapshot?.recordFeatureInteractionForTour ?? false, @@ -784,6 +807,8 @@ export function FloatingTerminalPanel({ } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + } else if (item.contentType === 'simulator') { + closeUnifiedTab(item.id) } else { const file = state.openFiles.find((candidate) => candidate.id === item.entityId) if (file?.isDirty) { @@ -797,7 +822,7 @@ export function FloatingTerminalPanel({ queueEditorCloseRequests(dirtyEditorFileIds) } }, - [activeGroup, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] + [activeGroup, closeBrowserTab, closeFile, closeTab, closeUnifiedTab, queueEditorCloseRequests] ) const closeFloatingItem = useCallback( @@ -868,7 +893,13 @@ export function FloatingTerminalPanel({ closeFloatingItems( currentGroupTabs .filter( - (tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser' && !tab.isPinned + (tab) => + tab.contentType !== 'terminal' && + tab.contentType !== 'browser' && + // Why: simulator tabs are not files; "Close All Files" must leave + // the Mobile Emulator open like terminal/browser tabs do. + tab.contentType !== 'simulator' && + !tab.isPinned ) .map((tab) => tab.id) ) @@ -1375,6 +1406,7 @@ export function FloatingTerminalPanel({ browserTabs={browserItems} activeFileId={activeEditorUnifiedId} activeBrowserTabId={activeBrowserId} + activeSimulatorTabId={activeTab?.contentType === 'simulator' ? activeTab.id : null} activeTabType={activeTabType} onActivateFile={activateFloatingItem} onCloseFile={closeFloatingItem} @@ -1464,6 +1496,18 @@ export function FloatingTerminalPanel({

) })} + {simulatorItems.map((tab) => { + const isActive = tab.id === activeTab?.id + return ( +
+ +
+ ) + })} {activeEditorFile ? (
e.stopPropagation()} onClick={(e) => { e.stopPropagation() diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index ff6406b5edb..6fcb98ab180 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -158,14 +158,23 @@ describe('store selectors', () => { sortOrder: 2, createdAt: 3 }, + { + id: 'simulator-1', + entityId: 'simulator-1', + worktreeId, + contentType: 'simulator', + label: 'Mobile Emulator', + sortOrder: 3, + createdAt: 4 + }, { id: 'unified-stale-terminal', entityId: 'missing-term', worktreeId, contentType: 'terminal', label: 'Stale', - sortOrder: 3, - createdAt: 4 + sortOrder: 4, + createdAt: 5 } ] as AppState['unifiedTabsByWorktree'][string] const state = { @@ -175,10 +184,10 @@ describe('store selectors', () => { unifiedTabsByWorktree: { [worktreeId]: unifiedTabs } } satisfies Parameters[0] - expect(selectFloatingVisibleTabCount(state)).toBe(3) + expect(selectFloatingVisibleTabCount(state)).toBe(4) expect(openFileScans).toBe(1) - expect(selectFloatingVisibleTabCount({ ...state })).toBe(3) + expect(selectFloatingVisibleTabCount({ ...state })).toBe(4) expect(openFileScans).toBe(1) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 53a03d2e3d9..cadec9abe45 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -224,6 +224,10 @@ export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountStat count += terminalIds.has(tab.entityId) ? 1 : 0 } else if (tab.contentType === 'browser') { count += browserIds.has(tab.entityId) ? 1 : 0 + } else if (tab.contentType === 'simulator') { + // Why: simulator unified tabs have no separate backing record; the tab + // itself is the visible floating workspace item. + count += 1 } else { count += editorIds.has(tab.entityId) ? 1 : 0 } diff --git a/tests/e2e/floating-mobile-emulator-tab.spec.ts b/tests/e2e/floating-mobile-emulator-tab.spec.ts new file mode 100644 index 00000000000..cf68477df9d --- /dev/null +++ b/tests/e2e/floating-mobile-emulator-tab.spec.ts @@ -0,0 +1,125 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// E2E specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' +const OPEN_PANEL_SELECTOR = `${PANEL_SELECTOR}[aria-hidden="false"]` +const TOGGLE_EVENT = 'orca-toggle-floating-terminal' + +type SeededSimulatorTab = { + id: string +} +type E2ESimulatorTab = { + id: string + contentType: string +} + +type E2EStoreState = { + settings: Record + unifiedTabsByWorktree: Record + createUnifiedTab: ( + worktreeId: string, + contentType: 'simulator', + init: { label: string; recordInteraction: false } + ) => E2ESimulatorTab + activateTab: (tabId: string) => void +} + +type E2EStore = { + getState: () => E2EStoreState + setState: (partial: Partial) => void +} + +type E2EWindow = typeof window & { + __store?: E2EStore +} + +async function seedFloatingSimulatorTab(page: Page): Promise { + const tab = await page.evaluate((worktreeId) => { + const store = (window as E2EWindow).__store + if (!store) { + throw new Error('Store unavailable') + } + + const state = store.getState() + store.setState({ + settings: { + ...state.settings, + floatingTerminalEnabled: true, + mobileEmulatorEnabled: true + } + }) + + const refreshedState = store.getState() + const existingTab = (refreshedState.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'simulator' + ) + const tab = + existingTab ?? + refreshedState.createUnifiedTab(worktreeId, 'simulator', { + label: 'Mobile Emulator', + recordInteraction: false + }) + refreshedState.activateTab(tab.id) + return { id: tab.id } + }, FLOATING_WORKTREE_ID) + // Why: the toggle listener closes over floatingTerminalEnabled; wait for + // React to commit the enabled floating panel before dispatching the event. + await page.waitForFunction( + (panelSelector) => Boolean(document.querySelector(panelSelector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + return tab +} + +async function openFloatingPanelIfNeeded(page: Page): Promise { + if ((await page.locator(OPEN_PANEL_SELECTOR).count()) === 0) { + await page.evaluate((eventName) => { + window.dispatchEvent(new Event(eventName)) + }, TOGGLE_EVENT) + } + await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible() +} + +async function attachPanelScreenshot(page: Page, testInfo: TestInfo): Promise { + const panel = page.locator(OPEN_PANEL_SELECTOR).first() + const body = await panel.screenshot() + await testInfo.attach('floating-mobile-emulator-open', { body, contentType: 'image/png' }) +} + +test('floating Mobile Emulator tab renders content and closes from the tab strip', async ({ + orcaPage +}, testInfo) => { + const tab = await seedFloatingSimulatorTab(orcaPage) + await openFloatingPanelIfNeeded(orcaPage) + + const openPanel = orcaPage.locator(OPEN_PANEL_SELECTOR).first() + const tabLocator = openPanel.locator(`[data-tab-id="${tab.id}"]`) + await expect(tabLocator).toBeVisible() + await expect(openPanel.locator('[data-emulator-pane]')).toBeVisible() + await attachPanelScreenshot(orcaPage, testInfo) + + await tabLocator.hover() + await tabLocator.locator('[data-tab-close-button="true"]').click() + + await expect + .poll( + async () => + orcaPage.evaluate( + ({ worktreeId, tabId }) => { + const tabs = + (window as E2EWindow).__store?.getState().unifiedTabsByWorktree[worktreeId] ?? [] + return tabs.some((candidate) => candidate.id === tabId) + }, + { worktreeId: FLOATING_WORKTREE_ID, tabId: tab.id } + ), + { timeout: 10_000 } + ) + .toBe(false) + + await expect(tabLocator).toHaveCount(0) + await expect(openPanel.locator('[data-emulator-pane]')).toHaveCount(0) +}) From a0d9505ba515bf85c1c764d39e5eedbeb9d6be24 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:42:20 -0700 Subject: [PATCH 05/65] Fix SSH terminal replay artifacts Fix SSH reconnect replay ownership and add a Docker-backed regression harness for terminal replay artifacts. --- .../run-ssh-codex-artifacts-repro-e2e.mjs | 45 +++ package.json | 1 + src/main/ipc/ssh.test.ts | 2 + src/main/providers/ssh-pty-provider.test.ts | 12 + src/main/providers/ssh-pty-provider.ts | 10 + src/main/ssh/ssh-relay-session.test.ts | 66 +++- src/main/ssh/ssh-relay-session.ts | 42 ++- .../terminal-pane/pty-connection.test.ts | 48 +++ .../terminal-pane/pty-connection.ts | 28 +- .../ssh-codex-display-artifacts-repro.spec.ts | 199 ++++++++++++ tests/e2e/ssh-codex-real-remote.ts | 59 ++++ .../e2e/ssh-codex-reconnect-replay-driver.ts | 234 +++++++++++++++ tests/e2e/ssh-codex-repro-remote-fixtures.ts | 233 ++++++++++++++ tests/e2e/ssh-codex-terminal-observers.ts | 147 +++++++++ .../e2e/terminal-raster-artifact-analysis.ts | 283 ++++++++++++++++++ tests/e2e/terminal-repro-evidence.ts | 19 ++ tests/e2e/terminal-webgl-reset-capture.ts | 30 ++ 17 files changed, 1446 insertions(+), 12 deletions(-) create mode 100644 config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs create mode 100644 tests/e2e/ssh-codex-display-artifacts-repro.spec.ts create mode 100644 tests/e2e/ssh-codex-real-remote.ts create mode 100644 tests/e2e/ssh-codex-reconnect-replay-driver.ts create mode 100644 tests/e2e/ssh-codex-repro-remote-fixtures.ts create mode 100644 tests/e2e/ssh-codex-terminal-observers.ts create mode 100644 tests/e2e/terminal-raster-artifact-analysis.ts create mode 100644 tests/e2e/terminal-repro-evidence.ts create mode 100644 tests/e2e/terminal-webgl-reset-capture.ts diff --git a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs new file mode 100644 index 00000000000..a4e3fec42ed --- /dev/null +++ b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process' + +const extraArgs = process.argv.slice(2) +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_SSH_DOCKER: '1' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { + stdio: 'inherit', + env +}) + +if (runtime.error) { + throw runtime.error +} +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + { + stdio: 'inherit', + env + } +) + +if (result.error) { + throw result.error +} +process.exit(result.status ?? 1) diff --git a/package.json b/package.json index 14869d56ecf..9ab4f4fefbf 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,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", + "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", "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs" diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 148d532716d..00a7bf20dc5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -57,6 +57,7 @@ const { onExit: vi.fn(), onReplay: vi.fn(), attach: vi.fn(), + attachForReconnect: vi.fn().mockResolvedValue({}), shutdown: vi.fn() }, mockFsProvider: {}, @@ -285,6 +286,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onData.mockReset() mockPtyProvider.onExit.mockReset() mockPtyProvider.onReplay.mockReset() + mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index ba4a47be8e3..9de9aa20b17 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -227,6 +227,18 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1' }) }) + it('attachForReconnect returns replay without relay notification', async () => { + mux.request.mockResolvedValue({ replay: 'restored output' }) + + const result = await provider.attachForReconnect(scopedPty1) + + expect(result).toEqual({ replay: 'restored output' }) + expect(mux.request).toHaveBeenCalledWith('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + }) + it('write sends pty.data notification', () => { provider.write(scopedPty1, 'hello') expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 327bbb9d7e5..56c4b94c737 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -177,6 +177,16 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) }) } + async attachForReconnect(id: string): Promise<{ replay?: string }> { + // Why: reconnect owns replay delivery so stale/duplicate attach results can + // be filtered before they reach the renderer. + const result = (await this.mux.request('pty.attach', { + id: this.toRelayPtyId(id), + suppressReplayNotification: true + })) as { replay?: string } | undefined + return result ?? {} + } + write(id: string, data: string): void { this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data }) } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 66c8650221e..9131375d58b 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -48,6 +48,7 @@ vi.mock('../providers/ssh-pty-provider', () => ({ onReplay = vi.fn().mockReturnValue(() => {}) onExit = vi.fn().mockReturnValue(() => {}) attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) dispose = vi.fn() } })) @@ -67,7 +68,8 @@ vi.mock('../ipc/pty', () => ({ unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined) + attach: vi.fn().mockResolvedValue(undefined), + attachForReconnect: vi.fn().mockResolvedValue({}) }), getPtyIdsForConnection: vi.fn().mockReturnValue([]), clearPtyOwnershipForConnection: vi.fn(), @@ -355,7 +357,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) @@ -366,12 +368,60 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-2') }) + it('forwards reconnect replay after the attach attempt is still current', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.clearAllMocks() + mockDeploySuccess() + + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' }) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) + + await session.reconnect(mockConn) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { + id: 'ssh:target-1@@pty-1', + data: 'restored-output' + }) + }) + + it('drops identical reconnect replay payloads inside one reconnect burst', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.clearAllMocks() + mockDeploySuccess() + + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' }) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) + + await session.reconnect(mockConn) + await session.reconnect(mockConn) + + const replaySends = vi + .mocked(mockWindow.webContents.send) + .mock.calls.filter(([channel]) => channel === 'pty:replay') + expect(mockAttach).toHaveBeenCalledTimes(2) + expect(replaySends).toHaveLength(1) + }) + it('establish re-attaches owned PTYs after explicit disconnect', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) @@ -390,7 +440,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue([]) @@ -419,7 +469,7 @@ describe('SshRelaySession', () => { }) ) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) @@ -454,7 +504,7 @@ describe('SshRelaySession', () => { }) ) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) @@ -486,7 +536,7 @@ describe('SshRelaySession', () => { .mockRejectedValueOnce(new Error('PTY "pty-stale" not found')) .mockResolvedValueOnce(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live']) @@ -515,7 +565,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed')) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live']) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index ecbf8b280c1..d124aca8263 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -73,6 +73,14 @@ type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } +type ForwardedReplayFingerprint = { + fingerprint: string + deliveredAt: number +} + +const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 +const REPLAY_FINGERPRINT_EDGE_CHARS = 128 + function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS @@ -111,6 +119,7 @@ export class SshRelaySession { private currentConnection: SshConnection | null = null private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null + private forwardedReattachReplayByPty = new Map() constructor( readonly targetId: string, @@ -950,6 +959,7 @@ export class SshRelaySession { const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) + this.forwardedReattachReplayByPty.delete(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) const win = this.getMainWindow() @@ -959,6 +969,34 @@ export class SshRelaySession { }) } + private replayFingerprint(data: string): string { + const head = data.slice(0, REPLAY_FINGERPRINT_EDGE_CHARS) + const tail = data.slice(-REPLAY_FINGERPRINT_EDGE_CHARS) + return `${data.length}:${head}:${tail}` + } + + private shouldForwardReattachReplay(appPtyId: string, data: string): boolean { + const now = Date.now() + const fingerprint = this.replayFingerprint(data) + const previous = this.forwardedReattachReplayByPty.get(appPtyId) + this.forwardedReattachReplayByPty.set(appPtyId, { fingerprint, deliveredAt: now }) + return ( + !previous || + previous.fingerprint !== fingerprint || + now - previous.deliveredAt > RECONNECT_REPLAY_DUPLICATE_WINDOW_MS + ) + } + + private forwardReattachReplay(appPtyId: string, data: string): void { + if (!data || !this.shouldForwardReattachReplay(appPtyId, data)) { + return + } + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:replay', { id: appPtyId, data }) + } + } + private async reattachKnownPtys(shouldContinue: () => boolean): Promise { const leasedPtyIds = this.store .getSshRemotePtyLeases(this.targetId) @@ -983,13 +1021,14 @@ export class SshRelaySession { return } try { - await ptyProvider.attach(ptyId) + const attachResult = (await ptyProvider.attachForReconnect(ptyId)) ?? {} if (!shouldContinue()) { return } const appPtyId = toAppSshPtyId(this.targetId, ptyId) setPtyOwnership(appPtyId, this.targetId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached') + this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } catch (err) { if (!isSshPtyNotFoundError(err)) { throw err @@ -1002,6 +1041,7 @@ export class SshRelaySession { const appPtyId = toAppSshPtyId(this.targetId, ptyId) clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) + this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') // Why: if the new relay cannot reattach this id, the remote backing // process is gone. Tell the renderer so it clears stale pane bindings 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 af5821fdb1e..ab99e7b28a3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6383,6 +6383,54 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('coalesces remote replay payloads that overlap before parsing starts', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const pendingParses: (() => void)[] = [] + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + if (callback) { + pendingParses.push(callback) + } + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('first replay') + capturedReplayCallback.current?.('second replay') + await flushAsyncTicks(2) + + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenNthCalledWith( + 1, + '\x1b[2J\x1b[3J\x1b[H', + expect.any(Function) + ) + + for (let index = 0; index < 8; index += 1) { + await flushAsyncTicks(2) + pendingParses.shift()?.() + } + await flushAsyncTicks(4) + + expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function)) + expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1) + disposable.dispose() + }) + it('does not switch renderers for Arabic output', 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 70d9edcc364..5807981a5c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2491,8 +2491,13 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } - const replayDataCallback = (data: string): void => { - void (async () => { + let replayWriteQueue = Promise.resolve() + let pendingReplayData: string | null = null + let replayDrainQueued = false + const drainReplayDataQueue = async (): Promise => { + while (pendingReplayData !== null) { + const data = pendingReplayData + pendingReplayData = null // Relay replay buffer holds the last 100 KB of output, which may // overlap with content already rendered in xterm before the // disconnect. Clear first to prevent duplication on SSH reconnect. @@ -2500,13 +2505,30 @@ export function connectPanePty( await writeReplayDataAsync(data) await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) if (disposed) { + pendingReplayData = null return } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) - })() + } + } + const replayDataCallback = (data: string): void => { + pendingReplayData = data + if (replayDrainQueued) { + return + } + replayDrainQueued = true + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(drainReplayDataQueue) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + replayDataCallback(pendingReplayData) + } + }) } type PendingHiddenOutputRestoreChunk = { diff --git a/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts b/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts new file mode 100644 index 00000000000..e19a090df4d --- /dev/null +++ b/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts @@ -0,0 +1,199 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT, + REMOTE_TUI_DONE, + installRemoteCodexArtifactTui, + installRemoteCodexFixture, + shellQuote +} from './ssh-codex-repro-remote-fixtures' +import { + connectDockerRemote, + dropDockerSshClientSessions, + enableRiskyTerminalRendererPath, + installPtyReplayProbe, + readDuplicateStatusRows, + readReplayProbeSnapshot, + switchToNonRemoteWorktree, + waitForDockerRemoteReconnected +} from './ssh-codex-reconnect-replay-driver' +import { installRemoteRealCodex, realRemoteCodexCommand } from './ssh-codex-real-remote' +import { + clearRemoteTerminalAfterCodex, + scrollActiveTerminalToArtifactHistory, + stressRestoreRemoteTerminalDuringCodex, + waitForRealRemoteCodexCompletion, + waitForRemoteFixtureCleanFinalInHiddenPane +} from './ssh-codex-terminal-observers' +import { MAX_FINAL_GRAY_SLABS, captureGraySlabAnalysis } from './terminal-raster-artifact-analysis' +import { persistReproEvidence } from './terminal-repro-evidence' +import { resetWebglAndCaptureGraySlabAnalysis } from './terminal-webgl-reset-capture' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const RUN_REAL_REMOTE_CODEX = process.env.ORCA_E2E_REAL_REMOTE_CODEX === '1' +const EXPECT_NO_ARTIFACTS = process.env.ORCA_E2E_EXPECT_NO_CODEX_ARTIFACTS === '1' +const CAPTURE_WHILE_REMOTE_TUI_RUNNING = + process.env.ORCA_E2E_CAPTURE_WHILE_REMOTE_TUI_RUNNING === '1' +const HIDE_UNTIL_REMOTE_TUI_DONE = process.env.ORCA_E2E_HIDE_UNTIL_REMOTE_TUI_DONE === '1' +const CAPTURE_SCROLLBACK_ARTIFACT_REGION = + process.env.ORCA_E2E_CAPTURE_SCROLLBACK_ARTIFACT_REGION === '1' +const FORCE_SSH_RECONNECT_DURING_TUI = process.env.ORCA_E2E_FORCE_SSH_RECONNECT_DURING_TUI === '1' +const KEEP_SSH_REPRO_TARGET = process.env.ORCA_E2E_KEEP_SSH_REPRO_TARGET === '1' + +test.describe('Remote SSH Codex display artifacts repro', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH repro.') + test.skip(process.platform === 'win32', 'Docker SSH repro uses POSIX ssh tooling.') + + test('does not leave duplicated Codex status output after SSH replay', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + installRemoteCodexArtifactTui(target) + if (RUN_REAL_REMOTE_CODEX) { + installRemoteRealCodex(target) + } else { + installRemoteCodexFixture(target) + } + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + expect(remote.targetId).toBeTruthy() + expect(remote.worktreeId).toBeTruthy() + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + await enableRiskyTerminalRendererPath(orcaPage) + await installPtyReplayProbe(orcaPage) + + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const doneMarker = RUN_REAL_REMOTE_CODEX + ? `ORCA_REAL_REMOTE_CODEX_DONE_${Date.now()}` + : REMOTE_TUI_DONE + const cleanMarker = RUN_REAL_REMOTE_CODEX + ? `ORCA_REAL_REMOTE_CODEX_CLEAN_${Date.now()}` + : doneMarker + await execInTerminal( + orcaPage, + ptyId, + RUN_REAL_REMOTE_CODEX + ? realRemoteCodexCommand(doneMarker) + : `codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox ${shellQuote( + doneMarker + )}` + ) + await orcaPage.waitForTimeout(1_200) + if (FORCE_SSH_RECONNECT_DURING_TUI) { + dropDockerSshClientSessions(target) + await waitForDockerRemoteReconnected(orcaPage, remote.targetId) + await orcaPage.waitForTimeout(2_000) + } + await (RUN_REAL_REMOTE_CODEX + ? (async () => { + await stressRestoreRemoteTerminalDuringCodex(orcaPage, remote.worktreeId) + await waitForRealRemoteCodexCompletion(orcaPage, doneMarker) + })() + : (async () => { + if (CAPTURE_WHILE_REMOTE_TUI_RUNNING) { + await orcaPage.waitForTimeout(10_000) + } else { + await switchToNonRemoteWorktree(orcaPage, remote.worktreeId) + await (HIDE_UNTIL_REMOTE_TUI_DONE + ? waitForRemoteFixtureCleanFinalInHiddenPane(orcaPage, remote.worktreeId) + : orcaPage.waitForTimeout(10_000)) + } + if (CAPTURE_WHILE_REMOTE_TUI_RUNNING) { + await orcaPage.waitForTimeout(900) + return + } + await switchToWorktree(orcaPage, remote.worktreeId) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForTerminalOutput( + orcaPage, + REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT, + 60_000, + 120_000 + ) + })()) + await orcaPage.waitForTimeout(600) + if (CAPTURE_SCROLLBACK_ARTIFACT_REGION) { + await scrollActiveTerminalToArtifactHistory(orcaPage) + } + + const { analysis, screenshot } = await captureGraySlabAnalysis(orcaPage) + analysis.replayDebug = await readReplayProbeSnapshot(orcaPage) + analysis.duplicateStatusRows = await readDuplicateStatusRows(orcaPage) + const evidenceLabel = RUN_REAL_REMOTE_CODEX + ? 'real-remote-codex-reconnect-replay' + : 'fixture-codex-reconnect-replay' + persistReproEvidence(evidenceLabel, analysis, screenshot) + const resetEvidence = await resetWebglAndCaptureGraySlabAnalysis(orcaPage) + resetEvidence.analysis.replayDebug = await readReplayProbeSnapshot(orcaPage) + resetEvidence.analysis.duplicateStatusRows = await readDuplicateStatusRows(orcaPage) + persistReproEvidence( + `${evidenceLabel}-after-webgl-reset`, + resetEvidence.analysis, + resetEvidence.screenshot + ) + await testInfo.attach('remote-codex-artifact-final-screen', { + body: screenshot, + contentType: 'image/png' + }) + await testInfo.attach('remote-codex-artifact-after-webgl-reset', { + body: resetEvidence.screenshot, + contentType: 'image/png' + }) + testInfo.annotations.push({ + type: 'remote-codex-artifact-analysis', + description: JSON.stringify(analysis) + }) + testInfo.annotations.push({ + type: 'remote-codex-artifact-after-webgl-reset-analysis', + description: JSON.stringify(resetEvidence.analysis) + }) + + // Why: this spec supports both repro mode and strict regression mode so + // the same harness can prove a failure and lock the fixed behavior. + if (EXPECT_NO_ARTIFACTS) { + expect(analysis.slabCount).toBeLessThanOrEqual(MAX_FINAL_GRAY_SLABS) + expect(analysis.staleStatusGlyphRowCount).toBe(0) + expect(analysis.duplicateStatusRows ?? []).toEqual([]) + } else { + expect(analysis.rawSlabCount + analysis.staleStatusGlyphRowCount).toBeGreaterThan(0) + } + if (FORCE_SSH_RECONNECT_DURING_TUI) { + expect(Number(analysis.replayDebug?.replayCount ?? 0)).toBeGreaterThan(0) + } + if (RUN_REAL_REMOTE_CODEX) { + await clearRemoteTerminalAfterCodex(orcaPage, ptyId, cleanMarker) + } + } finally { + if (KEEP_SSH_REPRO_TARGET && target) { + console.log( + `[ssh-codex-repro] keeping Docker SSH target ${target.containerName} on port ${target.port}` + ) + } else { + cleanupDockerSshRelayTarget(target) + } + } + }) +}) diff --git a/tests/e2e/ssh-codex-real-remote.ts b/tests/e2e/ssh-codex-real-remote.ts new file mode 100644 index 00000000000..60c96eb0d1e --- /dev/null +++ b/tests/e2e/ssh-codex-real-remote.ts @@ -0,0 +1,59 @@ +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { dockerExec, dockerWriteFile, shellQuote } from './ssh-codex-repro-remote-fixtures' + +const REMOTE_CODEX_VERSION = '0.141.0' + +function tomlString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') +} + +export function installRemoteRealCodex(target: DockerSshRelayTarget): void { + const codexAuthPath = path.join(os.homedir(), '.codex', 'auth.json') + if (!existsSync(codexAuthPath)) { + throw new Error(`Real remote Codex repro needs local auth at ${codexAuthPath}`) + } + dockerExec(target, 'mkdir -p /root/.codex') + dockerWriteFile(target, '/root/.codex/auth.json', readFileSync(codexAuthPath), '600') + const trustedRemotePath = tomlString(DOCKER_SSH_RELAY_REMOTE_REPO_PATH) + dockerWriteFile( + target, + '/root/.codex/config.toml', + [ + 'approval_policy = "never"', + '', + `[projects."${trustedRemotePath}"]`, + 'trust_level = "trusted"', + '' + ].join('\n'), + '600' + ) + dockerExec(target, `npm install -g @openai/codex@${REMOTE_CODEX_VERSION}`, 180_000) +} + +export function realRemoteCodexCommand(doneMarker: string): string { + const prompt = [ + 'This is an automated terminal rendering reproduction.', + 'Run these three shell commands one at a time, waiting for each one to finish before starting the next:', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_0_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_1_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_2_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'The commands are intentionally slow. Keep waiting until all three complete.', + 'Then briefly summarize that all three commands ran.', + `End your final response with this exact marker: ${doneMarker}` + ].join(' ') + return [ + 'codex', + '--no-alt-screen', + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-hook-trust', + '-C', + shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH), + shellQuote(prompt) + ].join(' ') +} diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts new file mode 100644 index 00000000000..75fc21a47e5 --- /dev/null +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -0,0 +1,234 @@ +import { execFileSync } from 'node:child_process' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' + +export type ConnectedDockerRemote = { + targetId: string + worktreeId: string +} + +export function dropDockerSshClientSessions(target: DockerSshRelayTarget): void { + execFileSync( + 'docker', + [ + 'exec', + target.containerName, + 'bash', + '-lc', + `ps -eo pid=,comm=,args= | awk '$2 == "sshd" && index($0, "sshd: root") { print $1 }' | xargs -r kill -9` + ], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 } + ) +} + +export async function connectDockerRemote( + page: Page, + target: DockerSshRelayTarget +): Promise { + return await page.evaluate( + async ({ target, remotePath }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const createdTarget = await window.api.ssh.addTarget({ + target: { + label: `Docker SSH Codex Artifact Repro ${Date.now()}`, + host: '127.0.0.1', + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + } + }) + const state = await window.api.ssh.connect({ targetId: createdTarget.id }) + if (!state || state.status !== 'connected') { + throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) + } + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + + const result = await window.api.repos.addRemote({ + connectionId: createdTarget.id, + remotePath, + displayName: 'Docker SSH Codex Artifact Repro' + }) + if ('error' in result) { + throw new Error(result.error) + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(result.repo.id) + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return { targetId: createdTarget.id, worktreeId: worktree.id } + } finally { + credentialUnsub() + } + }, + { target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } + ) +} + +export async function switchToNonRemoteWorktree( + page: Page, + remoteWorktreeId: string +): Promise { + const otherWorktreeId = await page.evaluate((remoteWorktreeId) => { + const store = window.__store + if (!store) { + return null + } + const state = store.getState() + const other = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id !== remoteWorktreeId) + if (!other) { + return null + } + state.setActiveWorktree(other.id) + return other.id + }, remoteWorktreeId) + if (!otherWorktreeId) { + throw new Error('No non-remote worktree available to hide the SSH terminal') + } + return otherWorktreeId +} + +export async function installPtyReplayProbe(page: Page): Promise { + await page.evaluate(() => { + const api = window.api?.pty + if (!api || typeof api.onReplay !== 'function') { + throw new Error('PTY replay API unavailable') + } + const holder = window as unknown as { + __orcaSshCodexReplayProbe?: { + payloads: { id: string; length: number; preview: string }[] + dispose: () => void + } + } + holder.__orcaSshCodexReplayProbe?.dispose() + const payloads: { id: string; length: number; preview: string }[] = [] + const dispose = api.onReplay(({ id, data }) => { + payloads.push({ + id, + length: data.length, + preview: data.slice(-400) + }) + }) + holder.__orcaSshCodexReplayProbe = { payloads, dispose } + }) +} + +export async function waitForDockerRemoteReconnected(page: Page, targetId: string): Promise { + let observedNonConnected = false + await expect + .poll( + async () => { + const status = await page.evaluate((targetId) => { + const state = window.__store?.getState().sshConnectionStates.get(targetId) + return state?.status ?? null + }, targetId) + if (status !== 'connected') { + observedNonConnected = true + } + return observedNonConnected && status === 'connected' + }, + { + timeout: 90_000, + message: 'Docker SSH target did not auto-reconnect after transport drop' + } + ) + .toBe(true) +} + +export async function readReplayProbeSnapshot(page: Page): Promise> { + return page.evaluate(() => { + const probe = ( + window as unknown as { + __orcaSshCodexReplayProbe?: { + payloads: { id: string; length: number; preview: string }[] + } + } + ).__orcaSshCodexReplayProbe + return { + replayCount: probe?.payloads.length ?? 0, + replayPayloads: probe?.payloads.slice(-8) ?? [] + } + }) +} + +export async function readDuplicateStatusRows(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const text = pane?.serializeAddon?.serialize?.() ?? '' + const counts = new Map() + const escapeSequencePattern = new RegExp( + `${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, + 'g' + ) + for (const line of text.split(/\r?\n/)) { + const normalized = line.replace(escapeSequencePattern, '').trim() + if (!/gpt-5\.5|background terminal|\/ps to view|\/stop to close/i.test(normalized)) { + continue + } + counts.set(normalized, (counts.get(normalized) ?? 0) + 1) + } + return Array.from(counts) + .filter(([, count]) => count > 1) + .map(([line, count]) => `${count}x ${line}`) + .slice(0, 12) + }) +} + +export async function enableRiskyTerminalRendererPath(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const state = store.getState() + store.setState({ + settings: { + ...state.settings!, + terminalGpuAcceleration: 'on', + theme: 'dark' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + manager?.setTerminalGpuAcceleration('on') + }) +} diff --git a/tests/e2e/ssh-codex-repro-remote-fixtures.ts b/tests/e2e/ssh-codex-repro-remote-fixtures.ts new file mode 100644 index 00000000000..3ee48187e7c --- /dev/null +++ b/tests/e2e/ssh-codex-repro-remote-fixtures.ts @@ -0,0 +1,233 @@ +import { execFileSync } from 'node:child_process' +import type { DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' + +const REMOTE_TUI_PATH = '/tmp/orca-codex-display-artifacts-repro.mjs' +export const REMOTE_TUI_DONE = 'ORCA_REMOTE_CODEX_ARTIFACT_TUI_DONE' +export const REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT = + 'Any gray slab visible now is stale renderer state.' +const REMOTE_TUI_FRAMES = 900 +const REMOTE_CODEX_FIXTURE_FRAMES = parseEnvNumber(process.env.ORCA_E2E_CODEX_FIXTURE_FRAMES, 34) +const REMOTE_CODEX_FIXTURE_FRAME_DELAY_MS = parseEnvNumber( + process.env.ORCA_E2E_CODEX_FIXTURE_FRAME_DELAY_MS, + 45 +) + +function parseEnvNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function remoteCodexArtifactTuiScript(): string { + return ` +const cols = Number(process.env.COLUMNS || 120) +const statusWidth = Math.max(48, Math.min(cols - 4, 104)) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +function write(chunk) { + return new Promise((resolve) => process.stdout.write(chunk, resolve)) +} + +function pad(text, width) { + const raw = text.length > width ? text.slice(0, width) : text + return raw + ' '.repeat(Math.max(0, width - raw.length)) +} + +await write('\\x1b]0;codex\\x07') +await write('\\x1b[2J\\x1b[H\\x1b[?25l') + +for (let frame = 0; frame < ${REMOTE_TUI_FRAMES}; frame += 1) { + const statusRow = 8 + (frame % 13) + const priorRow = 8 + ((frame + 12) % 13) + const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'][frame % 8] + const bands = [ + { row: statusRow, width: statusWidth, text: \`\${spinner} Working for background terminal · frame \${String(frame).padStart(3, '0')}\` }, + { row: 27 + (frame % 9), width: Math.max(32, statusWidth - 18), text: \`gpt-5.5 high · ~/remote/repro/pr-5969 · /ps to view · /stop to close \${frame}\` }, + { row: 40 + (frame % 11), width: Math.max(28, statusWidth - 8), text: \`• Working for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\` } + ] + await write('\\x1b[?2026h') + await write(\`\\x1b[\${priorRow};1H\\x1b[2K\`) + await write(\`\\x1b[\${27 + ((frame + 8) % 9)};1H\\x1b[2K\`) + await write(\`\\x1b[\${40 + ((frame + 10) % 11)};1H\\x1b[2K\`) + await write('\\x1b[1;1H\\x1b[38;2;142;196;255mgpt-5.5 high\\x1b[0m ') + await write('\\x1b[38;2;106;176;76m~/remote/repro/pr-5969\\x1b[0m ') + await write('/ps to view · /stop to close') + await write('\\x1b[3;1H• Reproducing remote Codex SSH display artifacts with fast status movement') + await write('\\x1b[4;1H• The moving status band intentionally uses gray background during frames') + await write('\\x1b[5;1H• Final screen is clean; any remaining gray slab is stale renderer state') + for (const band of bands) { + await write(\`\\x1b[\${band.row};1H\\x1b[48;2;72;72;72m\${pad('', band.width)}\\x1b[0m\`) + await write(\`\\x1b[\${band.row};3H\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad(band.text, band.width - 4)}\\x1b[0m\`) + } + await write(\`\\x1b[22;1H\\x1b[38;2;106;176;76m+\${' added code '.repeat(8)}\${frame}\\x1b[0m\`) + await write(\`\\x1b[23;1H\\x1b[38;2;230;90;75m-\${' removed code '.repeat(8)}\${frame}\\x1b[0m\`) + await write(\`\\x1b[25;1H\\x1b[38;2;153;199;255m› \${'Run focused and full validation gates '.repeat(3)}\${frame}\\x1b[0m\`) + if (frame % 18 === 0) { + await write(\`\\x1b[52;1H\\x1b[0m• Waited for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\\r\\n\`) + } + await write('\\x1b[?2026l') + await sleep(8) +} + +await write('\\x1b[?2026h\\x1b[2J\\x1b[H') +await write('\\x1b[38;2;142;196;255mgpt-5.5 high\\x1b[0m ') +await write('\\x1b[38;2;106;176;76m~/remote/repro/pr-5969\\x1b[0m clean final frame\\r\\n\\r\\n') +await write('Final screen intentionally has no gray background bands.\\r\\n') +await write('If Orca leaves wide gray rectangles here, they are stale remote-PTY render artifacts.\\r\\n') +await write('${REMOTE_TUI_DONE}\\r\\n') +await write('\\x1b[?25h\\x1b[?2026l') +setTimeout(() => process.exit(0), 50) +` +} + +export function installRemoteCodexArtifactTui(target: DockerSshRelayTarget): void { + const body = remoteCodexArtifactTuiScript() + dockerWriteFile(target, REMOTE_TUI_PATH, body, '755') +} + +function remoteCodexFixtureScript(): string { + return `#!/usr/bin/env node +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const write = (chunk) => new Promise((resolve) => process.stdout.write(chunk, resolve)) +const cols = Number(process.env.COLUMNS || 120) +const rows = Number(process.env.LINES || 48) +const width = Math.max(42, cols - 4) +const marker = process.argv.join(' ').match(/ORCA_REMOTE_CODEX_ARTIFACT_TUI_DONE|ORCA_REAL_REMOTE_CODEX_DONE_[0-9]+/)?.[0] || '${REMOTE_TUI_DONE}' +const scrollTop = 1 +const viewportTop = Math.max(14, rows - 17) +const viewportBottom = Math.max(viewportTop + 8, rows - 3) + +function pad(text, size) { + const raw = text.length > size ? text.slice(0, size) : text + return raw + ' '.repeat(Math.max(0, size - raw.length)) +} + +async function grayLine(row, text) { + await write(\`\\x1b[\${row};1H\\x1b[48;2;72;72;72m\${pad('', width)}\\x1b[0m\`) + await write(\`\\x1b[\${row};3H\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad(text, width - 4)}\\x1b[0m\`) +} + +async function grayScrollLine(text) { + await write(\`\\x1b[48;2;72;72;72m\${pad(text, width)}\\x1b[0m\\r\\n\`) +} + +async function codexViewportFrame(frame) { + await write('\\x1b[?2026h') + await write('\\x1b[?25l') + await write(\`\\x1b[\${viewportTop};1H\\x1b[2K\\x1b[38;2;142;196;255m>_ OpenAI Codex\\x1b[0m \`) + await write('\\x1b[38;2;106;176;76mgpt-5.5 high\\x1b[0m ') + await write('\\x1b[38;2;180;180;180m/model to change · /ps to view · /stop to close\\x1b[0m') + await grayLine(viewportTop + 2 + (frame % 5), \`• Working for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\`) + await write(\`\\x1b[\${viewportTop + 8};1H\\x1b[2K\\x1b[38;2;153;199;255m› \${pad('Reviewing terminal renderer state after remote SSH output burst ' + frame, width - 4)}\\x1b[0m\`) + await write('\\x1b[?2026l') +} + +async function insertCodexHistory(frame) { + const historyTop = scrollTop + const historyBottom = Math.max(historyTop + 3, viewportTop - 1) + const cursorTop = Math.max(historyTop, historyBottom - 1) + await write(\`\\x1b[\${historyTop};\${historyBottom}r\`) + await write(\`\\x1b[\${cursorTop};1H\`) + for (let index = 0; index < 3; index += 1) { + const phase = String(frame).padStart(4, '0') + '.' + index + await write('\\r\\n') + await write(\`\\x1b[48;2;72;72;72m\\x1b[K\`) + await write(\`\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad('gpt-5.5 high · ~/code/pr-12250-migration-compare-move-baseprice-claim · /ps to view · /stop to close ' + phase, width)}\\x1b[0m\`) + } + await write('\\x1b[r') + await write(\`\\x1b[\${viewportBottom};1H\`) +} + +async function reverseIndexCodexHistory(frame) { + const historyTop = scrollTop + const historyBottom = Math.max(historyTop + 4, viewportTop - 1) + const scrollAmount = 2 + (frame % 3) + await write(\`\\x1b[\${historyTop};\${rows}r\`) + await write(\`\\x1b[\${viewportTop};1H\`) + for (let index = 0; index < scrollAmount; index += 1) { + await write('\\x1bM') + } + await write('\\x1b[r') + await write(\`\\x1b[\${historyTop};\${historyBottom}r\`) + await write(\`\\x1b[\${historyBottom};1H\`) + for (let index = 0; index < scrollAmount; index += 1) { + await write('\\r\\n') + await write(\`\\x1b[48;2;72;72;72m\\x1b[K\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad('• Waited for background terminal · rtk bun run e2e:ui --filter=@dalp/app ' + frame + ':' + index, width)}\\x1b[0m\`) + } + await write('\\x1b[r') + await write(\`\\x1b[\${viewportBottom};1H\`) +} + +await write('\\x1b]0;codex\\x07') +await write('\\x1b[?25l') +await write('>_ OpenAI Codex (fixture)\\r\\n') +await write('model: gpt-5.5 /model to change\\r\\n') +await write('directory: ' + process.cwd() + '\\r\\n') +await write('permissions: YOLO mode\\r\\n\\r\\n') +await write('Tip: deterministic fixture for Orca SSH Codex display artifacts.\\r\\n\\r\\n') + +for (let frame = 0; frame < ${REMOTE_CODEX_FIXTURE_FRAMES}; frame += 1) { + await codexViewportFrame(frame) + await insertCodexHistory(frame) + if (frame % 4 === 0) { + await reverseIndexCodexHistory(frame) + } + if (frame % 9 === 0) { + await grayScrollLine(\`gpt-5.5 high · ~/code/pr-12250-migration-compare-move-baseprice-claim · /ps to view · /stop to close \${frame}\`) + } + await sleep(${REMOTE_CODEX_FIXTURE_FRAME_DELAY_MS}) +} + +await write('Updated Plan\\r\\n') +await write(' ✓ Reproduce remote Codex SSH display artifact\\r\\n') +await write(' ✓ Capture repeated gray status bands in scrollback\\r\\n') +await write('\\x1b[r\\x1b[?2026h\\x1b[2J\\x1b[H') +await write('Clean final frame after Codex-style gray status redraws.\\r\\n') +await write('There should be no gray background bands on this screen.\\r\\n') +await write('${REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT}\\r\\n') +await write(marker + '\\r\\n') +await write('\\x1b[?25h\\x1b[?2026l') +` +} + +export function installRemoteCodexFixture(target: DockerSshRelayTarget): void { + dockerWriteFile(target, '/usr/local/bin/codex', remoteCodexFixtureScript(), '755') +} + +export function dockerExec( + target: DockerSshRelayTarget, + command: string, + timeoutMs = 60_000 +): void { + execFileSync('docker', ['exec', target.containerName, 'bash', '-lc', command], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: timeoutMs + }) +} + +export function dockerWriteFile( + target: DockerSshRelayTarget, + remotePath: string, + body: string | Uint8Array, + mode: string +): void { + execFileSync( + 'docker', + [ + 'exec', + '-i', + target.containerName, + 'bash', + '-lc', + `cat > ${shellQuote(remotePath)} && chmod ${shellQuote(mode)} ${shellQuote(remotePath)}` + ], + { + input: body, + stdio: ['pipe', 'ignore', 'pipe'], + timeout: 60_000 + } + ) +} diff --git a/tests/e2e/ssh-codex-terminal-observers.ts b/tests/e2e/ssh-codex-terminal-observers.ts new file mode 100644 index 00000000000..276441779e8 --- /dev/null +++ b/tests/e2e/ssh-codex-terminal-observers.ts @@ -0,0 +1,147 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { ensureTerminalVisible, switchToWorktree } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT } from './ssh-codex-repro-remote-fixtures' +import { switchToNonRemoteWorktree } from './ssh-codex-reconnect-replay-driver' + +export async function waitForRemoteFixtureCleanFinalInHiddenPane( + page: Page, + remoteWorktreeId: string +): Promise { + await expect + .poll( + async () => + page.evaluate( + ({ remoteWorktreeId, cleanFinalText }) => { + const state = window.__store?.getState() + const tabId = state?.activeTabIdByWorktree?.[remoteWorktreeId] ?? null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.().includes(cleanFinalText) === true + }, + { remoteWorktreeId, cleanFinalText: REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT } + ), + { + timeout: 180_000, + message: 'Remote fixture did not reach its clean final frame while hidden' + } + ) + .toBe(true) +} + +export async function waitForRealRemoteCodexCompletion( + page: Page, + doneMarker: string +): Promise { + await expect + .poll( + async () => { + const content = await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }) + return content.split(doneMarker).length - 1 + }, + { + timeout: 300_000, + message: 'Real remote Codex did not emit its final marker' + } + ) + .toBeGreaterThanOrEqual(2) +} + +export async function clearRemoteTerminalAfterCodex( + page: Page, + ptyId: string, + cleanMarker: string +): Promise { + await sendToTerminal(page, ptyId, '/quit\r') + await waitForTerminalOutput(page, 'root@', 20_000, 120_000) + await execInTerminal( + page, + ptyId, + `printf '\\033[2J\\033[H${cleanMarker}\\nREAL_CODEX_CLEAN_SCREEN\\n'` + ) + await waitForTerminalOutput(page, cleanMarker, 20_000, 120_000) +} + +export async function waitForRealRemoteCodexBackgroundStatus(page: Page): Promise { + await expect + .poll( + async () => { + const content = await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }) + return /background terminal|Working for background terminal|REMOTE_CODEX_PHASE/i.test( + content + ) + }, + { + timeout: 180_000, + message: 'Real remote Codex did not enter the long-running background-command state' + } + ) + .toBe(true) +} + +export async function scrollActiveTerminalToArtifactHistory(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane to scroll') + } + const historyDepth = pane.terminal.buffer.active.baseY + pane.terminal.scrollToLine(Math.max(0, historyDepth - pane.terminal.rows * 3)) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) + await page.waitForTimeout(800) +} + +export async function stressRestoreRemoteTerminalDuringCodex( + page: Page, + remoteWorktreeId: string +): Promise { + for (let cycle = 0; cycle < 5; cycle += 1) { + await switchToNonRemoteWorktree(page, remoteWorktreeId) + await page.waitForTimeout(8_000) + await switchToWorktree(page, remoteWorktreeId) + await ensureTerminalVisible(page, 45_000) + await waitForActiveTerminalManager(page, 60_000) + await waitForRealRemoteCodexBackgroundStatus(page) + await page.waitForTimeout(900) + } +} diff --git a/tests/e2e/terminal-raster-artifact-analysis.ts b/tests/e2e/terminal-raster-artifact-analysis.ts new file mode 100644 index 00000000000..7658054229a --- /dev/null +++ b/tests/e2e/terminal-raster-artifact-analysis.ts @@ -0,0 +1,283 @@ +import { Buffer } from 'node:buffer' +import { PNG } from 'pngjs' +import type { Page } from '@stablyai/playwright-test' + +export type TerminalRasterTarget = { + clip: { x: number; y: number; width: number; height: number } + cellWidth: number + cellHeight: number + rows: number + cols: number + renderer: 'webgl' | 'dom' + modelGrayRows: number[] + modelStatusRows: number[] +} + +export type GraySlab = { + x: number + y: number + width: number + height: number +} +export type GraySlabAnalysis = { + slabCount: number + slabs: GraySlab[] + rawSlabCount: number + rawSlabs: GraySlab[] + staleStatusGlyphRowCount: number + staleStatusGlyphRows: number[] + target: TerminalRasterTarget + schedulerDebug: Record | null + replayDebug?: Record + duplicateStatusRows?: string[] +} + +export const MAX_FINAL_GRAY_SLABS = 0 + +async function readActiveTerminalRasterTarget(page: Page): Promise { + return page.evaluate(() => { + const isGrayRgb = (red: number, green: number, blue: number): boolean => { + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + return max - min <= 9 && max >= 48 && max <= 112 + } + + const isBufferGrayBackground = (cell: unknown): boolean => { + const record = cell as { + isBgRGB?: () => boolean + isBgPalette?: () => boolean + getBgColor?: () => number + } + if (record?.isBgRGB?.()) { + const color = record.getBgColor?.() ?? 0 + return isGrayRgb((color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff) + } + if (record?.isBgPalette?.()) { + const index = record.getBgColor?.() ?? -1 + const rgba = + pane.terminal._core?._themeService?.colors?.ansi?.[index]?.rgba ?? + pane.terminal._core?._themeService?.colors?.background?.rgba + if (typeof rgba !== 'number') { + return false + } + return isGrayRgb((rgba >> 24) & 0xff, (rgba >> 16) & 0xff, (rgba >> 8) & 0xff) + } + return false + } + + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane') + } + const screen = pane.container.querySelector('.xterm-screen') + const dimensions = pane.terminal._core?._renderService?.dimensions?.css?.cell + if (!screen || !dimensions) { + throw new Error('Active terminal has no measurable xterm screen') + } + const diagnostics = manager + ?.getRenderingDiagnostics() + .find((diagnostic) => diagnostic.paneId === pane.id) + const rect = screen.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + throw new Error('Active terminal screen is not visible for raster capture') + } + const activeBuffer = pane.terminal.buffer.active + const modelGrayRows: number[] = [] + const modelStatusRows: number[] = [] + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = activeBuffer.getLine(activeBuffer.viewportY + row) + const rowText = line?.translateToString(true) ?? '' + if (/gpt-5\.5|background terminal|\/ps to view|\/stop to close/i.test(rowText)) { + modelStatusRows.push(row) + } + let grayCellCount = 0 + for (let col = 0; col < pane.terminal.cols; col += 1) { + if (isBufferGrayBackground(line?.getCell(col))) { + grayCellCount += 1 + } + } + if (grayCellCount >= Math.min(8, Math.ceil(pane.terminal.cols * 0.08))) { + modelGrayRows.push(row) + } + } + return { + clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + cellWidth: dimensions.width, + cellHeight: dimensions.height, + rows: pane.terminal.rows, + cols: pane.terminal.cols, + renderer: diagnostics?.hasWebgl ? 'webgl' : 'dom', + modelGrayRows, + modelStatusRows + } + }) +} + +function isGraySlabPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 245) { + return false + } + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + return max - min <= 9 && max >= 48 && max <= 112 +} + +function isCodexStatusCyanPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 160) { + return false + } + return blue >= 160 && green >= 130 && red >= 80 && red <= 180 +} + +function isCodexStatusGreenPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 160) { + return false + } + return green >= 120 && red >= 60 && red <= 145 && blue >= 45 && blue <= 130 +} + +function analyzeGraySlabs( + buffer: Buffer, + target: TerminalRasterTarget, + viewport: { width: number; height: number } +): GraySlabAnalysis { + const image = PNG.sync.read(buffer) + const scaleX = image.width / viewport.width + const scaleY = image.height / viewport.height + const originX = Math.round(target.clip.x * scaleX) + const originY = Math.round(target.clip.y * scaleY) + const maxX = Math.min(image.width, originX + Math.round(target.clip.width * scaleX)) + const maxY = Math.min(image.height, originY + Math.round(target.clip.height * scaleY)) + // Tuned to ignore short gray fragments while still catching replay slab bands. + const minRunWidth = Math.max(32, Math.round(target.cellWidth * scaleX * 14)) + // Require multi-pixel vertical continuity to avoid one-line anti-alias noise. + const minRunHeight = Math.max(4, Math.round(target.cellHeight * scaleY * 0.35)) + const runs: GraySlab[] = [] + + for (let y = originY; y < maxY; y += 1) { + let runStart: number | null = null + for (let x = originX; x <= maxX; x += 1) { + const inside = x < maxX + const offset = (y * image.width + x) * 4 + const gray = + inside && + isGraySlabPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + if (gray && runStart === null) { + runStart = x + } else if (!gray && runStart !== null) { + const width = x - runStart + if (width >= minRunWidth) { + runs.push({ x: runStart - originX, y: y - originY, width, height: 1 }) + } + runStart = null + } + } + } + + const slabs: GraySlab[] = [] + for (const run of runs) { + const previous = slabs.at(-1) + if ( + previous && + Math.abs(previous.x - run.x) <= 3 && + Math.abs(previous.width - run.width) <= 8 && + previous.y + previous.height === run.y + ) { + previous.height += 1 + continue + } + slabs.push({ ...run }) + } + + const meaningfulSlabs = slabs.filter((slab) => slab.height >= minRunHeight) + const artifactSlabs = meaningfulSlabs.filter((slab) => { + const slabCenterCssY = (slab.y + slab.height / 2) / scaleY + const row = Math.floor(slabCenterCssY / target.cellHeight) + return !target.modelGrayRows.some((grayRow) => Math.abs(grayRow - row) <= 1) + }) + const statusGlyphRows: number[] = [] + for (let row = 0; row < target.rows; row += 1) { + let cyanPixelCount = 0 + let greenPixelCount = 0 + const yStart = originY + Math.round(row * target.cellHeight * scaleY) + const yEnd = Math.min(maxY, yStart + Math.round(target.cellHeight * scaleY)) + for (let y = yStart; y < yEnd; y += 1) { + for (let x = originX; x < maxX; x += 1) { + const offset = (y * image.width + x) * 4 + if ( + isCodexStatusCyanPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + ) { + cyanPixelCount += 1 + } else if ( + isCodexStatusGreenPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + ) { + greenPixelCount += 1 + } + } + } + if ( + row >= 8 && + // Thresholds tuned to classify stale Codex status glyph rows in screenshots. + cyanPixelCount >= Math.max(12, Math.round(target.cellWidth * scaleX * 3)) && + greenPixelCount >= Math.max(24, Math.round(target.cellWidth * scaleX * 8)) && + !target.modelStatusRows.some((statusRow) => Math.abs(statusRow - row) <= 1) + ) { + statusGlyphRows.push(row) + } + } + return { + slabCount: artifactSlabs.length, + slabs: artifactSlabs.slice(0, 12), + rawSlabCount: meaningfulSlabs.length, + rawSlabs: meaningfulSlabs.slice(0, 12), + staleStatusGlyphRowCount: statusGlyphRows.length, + staleStatusGlyphRows: statusGlyphRows.slice(0, 12), + target, + schedulerDebug: null + } +} + +export async function captureGraySlabAnalysis(page: Page): Promise<{ + analysis: GraySlabAnalysis + screenshot: Buffer +}> { + const target = await readActiveTerminalRasterTarget(page) + const schedulerDebug = await page.evaluate( + () => window.__terminalOutputSchedulerDebug?.snapshot?.() ?? null + ) + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + })) + const screenshot = Buffer.from(await page.screenshot()) + const analysis = analyzeGraySlabs(screenshot, target, viewport) + analysis.schedulerDebug = schedulerDebug + return { + analysis, + screenshot + } +} diff --git a/tests/e2e/terminal-repro-evidence.ts b/tests/e2e/terminal-repro-evidence.ts new file mode 100644 index 00000000000..1ae6bd0efa9 --- /dev/null +++ b/tests/e2e/terminal-repro-evidence.ts @@ -0,0 +1,19 @@ +import type { Buffer } from 'node:buffer' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { GraySlabAnalysis } from './terminal-raster-artifact-analysis' + +const REPRO_ARTIFACT_DIR = path.join(process.cwd(), '.tmp', 'issue-5969-repro') + +export function persistReproEvidence( + label: string, + analysis: GraySlabAnalysis, + screenshot: Buffer +): void { + mkdirSync(REPRO_ARTIFACT_DIR, { recursive: true }) + writeFileSync(path.join(REPRO_ARTIFACT_DIR, `${label}.png`), screenshot) + writeFileSync( + path.join(REPRO_ARTIFACT_DIR, `${label}.json`), + `${JSON.stringify(analysis, null, 2)}\n` + ) +} diff --git a/tests/e2e/terminal-webgl-reset-capture.ts b/tests/e2e/terminal-webgl-reset-capture.ts new file mode 100644 index 00000000000..93da7610ca1 --- /dev/null +++ b/tests/e2e/terminal-webgl-reset-capture.ts @@ -0,0 +1,30 @@ +import type { Buffer } from 'node:buffer' +import type { Page } from '@stablyai/playwright-test' +import { captureGraySlabAnalysis, type GraySlabAnalysis } from './terminal-raster-artifact-analysis' + +export async function resetWebglAndCaptureGraySlabAnalysis(page: Page): Promise<{ + analysis: GraySlabAnalysis + screenshot: Buffer +}> { + await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + manager?.resetWebglTextureAtlases?.() + pane?.terminal?.refresh?.(0, Math.max(0, (pane.terminal.rows ?? 1) - 1)) + }) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + return captureGraySlabAnalysis(page) +} From 44935d4ccb975b3d7636936ad5f9855faadc512f Mon Sep 17 00:00:00 2001 From: Kevin Chan Date: Tue, 23 Jun 2026 07:46:47 +0800 Subject: [PATCH 06/65] Fix remote Node.js detection for nvm, mise, asdf, and volta (#6037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix remote Node.js detection for nvm, mise, asdf, and volta Remote Node resolution failed when node was installed via a version manager (nvm with custom NVM_DIR, mise, asdf, volta) or when the user's login shell was zsh/fish rather than bash. Root cause: the SSH exec transport runs every command under /bin/sh, which never sources shell init files. The only init-aware path was a hardcoded `bash -lc` fallback that missed zsh/fish users and was never reached for the newer version managers. nvm was handled by guessing ~/.nvm (breaking custom NVM_DIR), and mise/asdf/volta had no probes at all. There was also no version gate, so nvm's highest-version glob could return Node 8/10/12 and crash the relay on launch. Fix: resolve via the user's own $SHELL as a login shell first (the only path that runs nvm.sh / mise activate / asdf.sh init hooks), then fall back to direct path probes for all major managers (nvm respecting $NVM_DIR, fnm, mise, asdf, volta, n) plus system locations. Every candidate is version-checked against the relay's Node 18+ requirement before being accepted. * Address CodeRabbit review: probes-first, no || short-circuit - Reorder to path-probes first (deterministic, doesn't depend on shell rc-file semantics where bash -lc skips .bashrc and zsh -lc skips .zshrc — exactly where nvm/mise/asdf hooks live). - Join probes with newlines instead of || so an empty `ls | sort -V | tail -1` (exit 0) doesn't mask later probes. - Deduplicate candidate paths before version-checking. - Drop unreachable mock and fix misleading $SHELL-unset test name. - Login shell is now a fallback for custom ~/.profile PATH setups. * Fix remote Node path probing portability Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../ssh/ssh-remote-node-resolution.test.ts | 283 ++++++++++++++++++ src/main/ssh/ssh-remote-node-resolution.ts | 201 ++++++++++--- 2 files changed, 445 insertions(+), 39 deletions(-) create mode 100644 src/main/ssh/ssh-remote-node-resolution.test.ts diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts new file mode 100644 index 00000000000..b007c8fd17c --- /dev/null +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -0,0 +1,283 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +const execCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: execCommandMock +})) + +// Why: await import() is required so vi.mock() above registers before the +// module under test is evaluated. Static import would bypass the mock. +const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') + +const conn = {} as SshConnection + +describe('resolveRemoteNodePath', () => { + beforeEach(() => { + execCommandMock.mockReset() + }) + + // ── Path-probe strategy (runs first) ─────────────────────────────────── + + it('resolves system node via the path probe', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') // path probe + .mockResolvedValueOnce('v20.0.0\n') // version check + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + }) + + it('probes mise install directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + }) + + it('probes asdf install directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.asdf/installs/nodejs/20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + }) + + it('probes volta bin directory', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.volta/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('$HOME/.volta/bin/node') + }) + + it('respects a custom NVM_DIR instead of hardcoding $HOME/.nvm', async () => { + execCommandMock + .mockResolvedValueOnce('/custom/nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}') + expect(callScript).toContain('NVM_DIR[[:space:]]*=') + expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node') + }) + + it('quotes version-manager directory prefixes while leaving globs active', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + }) + + it('does not depend on GNU sort when probing version-manager directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).not.toContain('sort -V') + }) + + it('keeps the path-probe script successful when optional directories are missing', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript.trimEnd()).toMatch(/\ntrue$/) + }) + + it('expands tilde NVM_DIR assignments from shell dotfiles', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-nvm-probe-')) + try { + const nodePath = path.join(home, 'tilde-nvm/versions/node/v20.11.0/bin/node') + mkdirSync(path.dirname(nodePath), { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(nodePath, 0o755) + writeFileSync(path.join(home, '.zshrc'), 'export NVM_DIR=~/tilde-nvm\n') + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + }) + + expect(output.split('\n')).toContain(nodePath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + it('joins probes with newlines, not ||, so a missing dir does not mask later probes', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + // Why: an `||` chain would stop after the first successful probe and hide + // later version managers that may hold the first usable Node. + expect(callScript).not.toMatch(/node\b.*\|\|/) + }) + + it('rejects a path-probe candidate whose version is below the minimum', async () => { + // Probe returns two candidates; the first (v10) is too old, the second + // (v20) must be selected instead. + execCommandMock + .mockResolvedValueOnce( + '/home/u/.nvm/versions/node/v10.24.1/bin/node\n/home/u/.nvm/versions/node/v20.11.0/bin/node\n' + ) + .mockResolvedValueOnce('v10.24.1\n') // first candidate fails the gate + .mockResolvedValueOnce('v20.11.0\n') // second candidate passes + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + }) + + it('accepts Node 18 (the exact minimum) as valid', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v18.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + }) + + it('deduplicates repeated candidate paths before version-checking', async () => { + // Why: some managers leave stale shims that resolve to the same binary; + // we should not version-check the same path twice. + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenCalledTimes(2) + }) + + it('falls back to the login shell when path probes find nothing', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/zsh') // $SHELL + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') // command -v node + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + + expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/zsh' -lc 'command -v node'`, { + wrapCommand: false, + timeoutMs: 8_000 + }) + }) + + it('falls back to the login shell when every path-probe candidate is too old', async () => { + execCommandMock + .mockResolvedValueOnce('/old/node\n') // path probe + .mockResolvedValueOnce('v10.24.1\n') // too old + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + }) + + // ── Login-shell strategy (fallback) ─────────────────────────────────── + + it('respects a non-default $SHELL instead of hardcoding bash', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/usr/bin/fish') // $SHELL + .mockResolvedValueOnce('/opt/homebrew/bin/node\n') + .mockResolvedValueOnce('v22.0.0\n') + + await resolveRemoteNodePath(conn) + + expect(execCommandMock).toHaveBeenNthCalledWith( + 3, + conn, + `'/usr/bin/fish' -lc 'command -v node'`, + { wrapCommand: false, timeoutMs: 8_000 } + ) + }) + + it('uses /bin/sh when the remote shell expansion falls back to it', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/sh\n') // ${SHELL:-/bin/sh} + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/sh' -c 'command -v node'`, { + wrapCommand: false, + timeoutMs: 8_000 + }) + }) + + // ── Failure ─────────────────────────────────────────────────────────── + + it('throws when both strategies find no usable node', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('\n') // command -v node: empty + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('throws when the path-probe exec fails and the login shell finds nothing', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors + .mockResolvedValueOnce('/bin/zsh') // $SHELL + .mockResolvedValueOnce('\n') // command -v node: empty + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('throws when every candidate across both strategies is below the minimum', async () => { + execCommandMock + .mockResolvedValueOnce('/old/node\n') // path probe + .mockResolvedValueOnce('v8.17.0\n') // too old + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/old/node2\n') // login shell + .mockResolvedValueOnce('v6.17.0\n') // too old + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) +}) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index e655894e924..a5ec9b9fd05 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -1,12 +1,20 @@ import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' +import { execCommand } from './ssh-relay-deploy-helpers' + +// Why: the relay requires Node.js 18+. Version managers like nvm keep every +// installed version on disk, so a naive "highest version" glob can hand back +// Node 8/10/12 and crash the relay on launch. Gate every candidate on this. +const MIN_NODE_MAJOR = 18 + +// Why: the login-shell fallback catches custom PATH setups in ~/.profile that +// the path probes don't cover. Interactive configs (conda prompts, etc.) can +// hang a login shell, so keep this short. +const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 -// Why: non-login SSH shells (the default for `exec`) don't source -// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH. -// We try common locations and fall back to a login-shell `which`. export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform @@ -15,48 +23,163 @@ export async function resolveRemoteNodePath( return resolveRemoteWindowsNodePath(conn) } - const script = [ - 'command -v node 2>/dev/null', - 'command -v /usr/local/bin/node 2>/dev/null', - 'command -v /opt/homebrew/bin/node 2>/dev/null', - // Why: nvm installs into a versioned directory. `ls -1` sorts - // alphabetically, which misorders versions (e.g. v9 > v18). Pipe - // through `sort -V` (version sort) so we pick the highest version. - 'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1', - 'command -v $HOME/.local/bin/node 2>/dev/null', - 'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null' - ].join(' || ') - - try { - const result = await execCommand(conn, script) - const nodePath = result.trim().split('\n')[0] - if (nodePath) { - console.log(`[ssh-relay] Found node at: ${nodePath}`) - return nodePath - } - } catch { - // Fall through to login shell attempt + // Strategy 1: probe well-known install directories for every common Node + // version manager (nvm, fnm, mise, asdf, volta, n) plus system locations. + // This doesn't depend on shell startup-file semantics — bash -lc skips + // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where + // nvm/mise/asdf hooks live. Probing directories directly is deterministic. + const probedPath = await tryResolveViaKnownPaths(conn) + if (probedPath) { + return probedPath } - // Why: last resort — source the full login profile. This is separated into - // its own exec because `bash -lc` can hang on remotes with interactive - // shell configs (conda prompts, etc.). If this times out, the error message - // from execCommand will tell us it was the login shell attempt. - try { - console.log('[ssh-relay] Trying login shell to find node...') - const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null") - const nodePath = result.trim().split('\n')[0] - if (nodePath) { - console.log(`[ssh-relay] Found node via login shell: ${nodePath}`) - return nodePath - } - } catch { - // Fall through + // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH + // setups in ~/.profile / ~/.bash_profile that the probes don't cover. + const loginShellPath = await tryResolveViaLoginShell(conn) + if (loginShellPath) { + return loginShellPath } throwNodeNotFound() } +// Probe the on-disk install directories of every common Node version manager +// plus system package-manager locations. Every probe runs unconditionally so +// a missing directory prints nothing rather than short-circuiting later +// probes. Returns the first candidate that meets the minimum version. +async function tryResolveViaKnownPaths(conn: SshConnection): Promise { + const script = ` +command -v node 2>/dev/null +nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} +for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" +do + [ -r "$nvm_file" ] || continue + nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1) + case "$nvm_dir_from_file" in + \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; + \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; + *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; + esac + case "$nvm_dir_from_file" in + '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; + "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; + esac + [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs +$nvm_dir_from_file" +done +printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir +do + [ -n "$nvm_dir" ] || continue + for candidate in "$nvm_dir"/versions/node/*/bin/node + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done +done +for candidate in \\ + /usr/local/bin/node \\ + /opt/homebrew/bin/node \\ + "$HOME/.local/bin/node" \\ + "$HOME/.fnm/aliases/default/bin/node" \\ + "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/mise/shims/node" \\ + "$HOME/.local/share/mise/installs/node"/*/bin/node \\ + "$HOME/.asdf/shims/node" \\ + "$HOME/.asdf/installs/nodejs"/*/bin/node \\ + "$HOME/.volta/bin/node" \\ + /usr/local/n/versions/node/*/bin/node +do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" +done +true +` + + try { + const result = await execCommand(conn, script) + const seen = new Set() + for (const line of result.split('\n')) { + const candidate = line.trim() + if (!candidate || seen.has(candidate)) { + continue + } + seen.add(candidate) + if (await nodeMeetsVersionRequirement(conn, candidate)) { + console.log(`[ssh-relay] Found node via path probe: ${candidate}`) + return candidate + } + } + } catch { + // Fall through to login shell. + } + return null +} + +// Run `command -v node` under the user's login shell, then verify the result +// meets the minimum version. Returns null on any failure (shell missing, no +// node found, version too old, timeout) so callers fall through to the error. +async function tryResolveViaLoginShell(conn: SshConnection): Promise { + try { + // Why: $SHELL is the user's configured login shell (set by chsh / passwd). + // Using it — rather than hardcoding bash — means zsh/fish users whose + // custom PATH hooks live in profile files get coverage too. We fall back + // to sh if $SHELL is unset (rare, e.g. restricted accounts). + const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + }) + const shell = shellResult.trim().split('\n')[0] + if (!shell) { + return null + } + + const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), { + wrapCommand: false, + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + }) + const candidate = nodePath.trim().split('\n')[0] + if (!candidate) { + return null + } + + if (await nodeMeetsVersionRequirement(conn, candidate)) { + console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`) + return candidate + } + } catch { + // Fall through. + } + return null +} + +function buildCommandInShell(shell: string, command: string): string { + const shellName = shell.split('/').at(-1) + // Why: dash and POSIX sh do not require `-l`; when $SHELL falls back to + // /bin/sh, prefer a portable command over login-shell semantics. + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` +} + +// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR. +// Caches nothing — this runs at most a few times per resolution (one per +// candidate), and the exec round-trip dominates. +async function nodeMeetsVersionRequirement( + conn: SshConnection, + nodePath: string +): Promise { + try { + const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { + wrapCommand: false + }) + const match = versionOutput.trim().match(/^v?(\d+)/) + if (!match) { + return false + } + const major = Number.parseInt(match[1]!, 10) + return major >= MIN_NODE_MAJOR + } catch { + // Binary missing or fails to run — not usable. + return false + } +} + async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { const script = [ '$paths = @()', From eddd78d5ba9bd0f045d49d472e50af147c19a3db Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:05:49 -0700 Subject: [PATCH 07/65] Update mobile Android APK links to 0.0.15 Point README and in-app Android download links at the published mobile-android-v0.0.15 APK release. --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.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/README.md b/README.md index b11d94924fc..bb86cc4c9e5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,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) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](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.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,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) -- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 6bedfaa2637..b15598c68bb 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -35,7 +35,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.14/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.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,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.14/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index f591da62b2f..665bebbfe3f 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -35,7 +35,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.14/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.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,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.14/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 105dd118bb3..5771cf35b49 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -35,7 +35,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.14/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.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,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.14/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c1f3343ca56..05c3f462019 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -35,7 +35,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.14/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.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,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.14/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/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 5aa1e173b99..a5a3d5660ed 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -23,6 +23,6 @@ export const PLATFORM_COPY: Record< ) }, ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk' } } diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index fe3be19d1a1..b5a5d850c1a 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -9,7 +9,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.14/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { return ( From c2353fd98cf1f00abb57e10a81694d097300e3e8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:30:50 -0700 Subject: [PATCH 08/65] Tune terminal scroll defaults (#6068) * Tune terminal scroll defaults * Align TUI mouse wheel scrolling * Add terminal scroll speed controls --- .../runtime-home-service.test.ts | 3 + src/main/codex-accounts/service.test.ts | 3 + src/main/persistence.test.ts | 3 + .../settings/TerminalInteractionSection.tsx | 190 ++++++++++++++++++ .../src/components/settings/setting-labels.ts | 3 + .../terminal-pane-appearance-search.ts | 17 ++ .../terminal-pane/terminal-appearance.ts | 12 +- .../use-terminal-pane-lifecycle.ts | 15 +- .../lib/pane-manager/pane-lifecycle.test.ts | 24 +++ .../src/lib/pane-manager/pane-lifecycle.ts | 6 + .../lib/pane-manager/pane-manager-types.ts | 2 + .../pane-terminal-mouse-wheel.test.ts | 81 ++++++++ .../pane-manager/pane-terminal-mouse-wheel.ts | 124 ++++++++++++ .../lib/pane-manager/pane-terminal-options.ts | 19 ++ src/shared/constants.ts | 3 + src/shared/types.ts | 3 + 16 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 20e3e85e56c..fd09a86fc08 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -60,6 +60,9 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index f8acfcf1e1c..3d899b25eae 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -64,6 +64,9 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fc67b98f78d..4109cb2b2ab 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -478,6 +478,9 @@ describe('Store', () => { expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) + expect(settings.terminalScrollSensitivity).toBe(1.15) + expect(settings.terminalFastScrollSensitivity).toBe(5) + expect(settings.terminalTuiScrollSensitivity).toBe(3) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index 705b0b7e784..b97b053ad9c 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -1,10 +1,24 @@ import type { GlobalSettings } from '../../../../shared/types' +import { RotateCcw } from 'lucide-react' +import { Slider } from '../ui/slider' +import { Button } from '../ui/button' +import { Label } from '../ui/label' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { getTerminalRightClickToPasteSearchEntry } from './terminal-windows-search' import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor' import { translate } from '@/i18n/i18n' +import { + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, + DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity +} from '@/lib/pane-manager/pane-terminal-options' +import { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + normalizeTerminalTuiMouseWheelMultiplier +} from '@/lib/pane-manager/pane-terminal-mouse-wheel' type TerminalInteractionSectionProps = { settings: GlobalSettings @@ -13,6 +27,65 @@ type TerminalInteractionSectionProps = { isWindows: boolean } +type ScrollSpeedSliderProps = { + label: string + description: string + value: number + min: number + max: number + step: number + suffix: string + onChange: (value: number) => void +} + +function formatScrollSpeedValue(value: number): string { + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function ScrollSpeedSlider({ + label, + description, + value, + min, + max, + step, + suffix, + onChange +}: ScrollSpeedSliderProps): React.JSX.Element { + return ( +
+
+
+ +

{description}

+
+ + {formatScrollSpeedValue(value)} + {suffix} + +
+ { + if (next !== undefined) { + onChange(next) + } + }} + /> +
+ {formatScrollSpeedValue(min)} + {formatScrollSpeedValue(max)} +
+
+ ) +} + export function TerminalInteractionSection({ settings, updateSettings, @@ -33,6 +106,123 @@ export function TerminalInteractionSection({ />
+ +
+
+
+ +

+ {translate( + 'auto.components.settings.TerminalPane.scrollSpeed.helper', + 'Adjust how wheel input feels in scrollback and in mouse-aware terminal apps.' + )} +

+
+ +
+
+ + updateSettings({ + terminalScrollSensitivity: normalizeTerminalScrollSensitivity(value) + }) + } + /> + + updateSettings({ + terminalFastScrollSensitivity: normalizeTerminalFastScrollSensitivity(value) + }) + } + /> + + updateSettings({ + terminalTuiScrollSensitivity: normalizeTerminalTuiMouseWheelMultiplier(value) + }) + } + /> +
+
+
+ {/* Why: the Windows-only right-click toggle lives in this section, so the section must also match that search term or settings search would hide the control even though it is present. */} diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts index 0f77138547d..cdc459d9790 100644 --- a/src/renderer/src/components/settings/setting-labels.ts +++ b/src/renderer/src/components/settings/setting-labels.ts @@ -4,6 +4,9 @@ export const SETTING_LABELS: Partial> = { terminalFontSize: 'Font Size', terminalFontFamily: 'Font Family', terminalFontWeight: 'Font Weight', + terminalScrollSensitivity: 'Normal Scroll Speed', + terminalFastScrollSensitivity: 'Fast Scroll Speed', + terminalTuiScrollSensitivity: 'TUI Scroll Speed', terminalBackgroundOpacity: 'Background Opacity', terminalCursorStyle: 'Cursor Style', terminalCursorBlink: 'Cursor Blink', diff --git a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts index ea86ccb9dfb..d77c5e68432 100644 --- a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts +++ b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts @@ -34,6 +34,23 @@ export const getTerminalPaneAppearanceSearchEntries = createLocalizedCatalog(() ]) export const getTerminalPaneInteractionSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.terminal.search.scrollSpeed.title', 'Scroll Speed'), + description: translate( + 'auto.components.settings.terminal.search.scrollSpeed.description', + 'Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.terminal.search.39ea7c0d28', 'terminal'), + ...translateSearchKeyword('auto.components.settings.terminal.search.scroll', 'scroll'), + ...translateSearchKeyword('auto.components.settings.terminal.search.scrolling', 'scrolling'), + ...translateSearchKeyword('auto.components.settings.terminal.search.speed', 'speed'), + ...translateSearchKeyword('auto.components.settings.terminal.search.wheel', 'wheel'), + ...translateSearchKeyword('auto.components.settings.terminal.search.ea364ce6e4', 'mouse'), + ...translateSearchKeyword('auto.components.settings.terminal.search.trackpad', 'trackpad'), + ...translateSearchKeyword('auto.components.settings.terminal.search.tui', 'tui') + ] + }, { title: translate( 'auto.components.settings.terminal.search.ask_before_closing_running_terminals_title', diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 9f7dc5f23d2..2ec75fc3fac 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -11,7 +11,11 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' -import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, + resolveTerminalCursorInactiveStyle +} from '@/lib/pane-manager/pane-terminal-options' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' @@ -235,6 +239,12 @@ export function applyTerminalAppearance( pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily) pane.terminal.options.fontWeight = terminalFontWeights.fontWeight pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold + pane.terminal.options.scrollSensitivity = normalizeTerminalScrollSensitivity( + settings.terminalScrollSensitivity + ) + pane.terminal.options.fastScrollSensitivity = normalizeTerminalFastScrollSensitivity( + settings.terminalFastScrollSensitivity + ) // Why: xterm's macOptionIsMeta only flips on the 'true' mode. 'left' and // 'right' are handled in the keydown policy (terminal-shortcut-policy), // which needs Option to stay composable at the xterm level for the 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 47efa7a455d..cfd5b6f398a 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 @@ -4,7 +4,12 @@ import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import { PaneManager } from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' -import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, + resolveTerminalCursorInactiveStyle +} from '@/lib/pane-manager/pane-terminal-options' +import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { useAppStore } from '@/store' import { @@ -1069,11 +1074,19 @@ export function useTerminalPaneLifecycle({ cursorStyle, cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle), cursorBlink: currentSettings?.terminalCursorBlink ?? true, + scrollSensitivity: normalizeTerminalScrollSensitivity( + currentSettings?.terminalScrollSensitivity + ), + fastScrollSensitivity: normalizeTerminalFastScrollSensitivity( + currentSettings?.terminalFastScrollSensitivity + ), macOptionIsMeta: effectiveMacOptionAsAltRef.current === 'true', lineHeight: currentSettings?.terminalLineHeight ?? 1, wordSeparator: currentSettings?.terminalWordSeparator } }, + terminalTuiScrollSensitivity: () => + normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), onLinkClick: (event, url) => { if (!event) { return diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 25174f917bf..bb24554e950 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -9,6 +9,10 @@ import { import { openTerminal } from './pane-lifecycle' import { buildDefaultTerminalOptions, + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, + DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, resolveTerminalCursorInactiveStyle } from './pane-terminal-options' @@ -38,6 +42,7 @@ function createPane(): ManagedPaneInternal { stablePaneId: leafId, terminal: { loadAddon: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), rows: 24 } as never, @@ -83,6 +88,24 @@ describe('buildDefaultTerminalOptions', () => { expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(7) }) + it('slightly increases default terminal wheel scrolling while preserving fast scroll', () => { + const options = buildDefaultTerminalOptions() + + expect(options.scrollSensitivity).toBe(DEFAULT_TERMINAL_SCROLL_SENSITIVITY) + expect(options.fastScrollSensitivity).toBe(DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY) + }) + + it('normalizes configurable terminal scroll sensitivity values', () => { + expect(normalizeTerminalScrollSensitivity(undefined)).toBe(DEFAULT_TERMINAL_SCROLL_SENSITIVITY) + expect(normalizeTerminalScrollSensitivity(0)).toBe(0.1) + expect(normalizeTerminalScrollSensitivity(20)).toBe(10) + expect(normalizeTerminalFastScrollSensitivity(undefined)).toBe( + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY + ) + expect(normalizeTerminalFastScrollSensitivity(0)).toBe(1) + expect(normalizeTerminalFastScrollSensitivity(25)).toBe(20) + }) + it('enables xterm contrast correction for low-contrast CLI colors', () => { expect(buildDefaultTerminalOptions().minimumContrastRatio).toBe(4.5) }) @@ -386,6 +409,7 @@ describe('openTerminal — Unicode 11 ordering', () => { events.push('loadAddon:webLinks') } }), + attachCustomWheelEventHandler: vi.fn(), write: vi.fn(() => { events.push('write') }), diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 4feec04fc30..66370b28546 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -25,6 +25,7 @@ import { import { clearPendingSplitScrollRestore } from './pane-split-scroll' import { buildDefaultTerminalOptions } from './pane-terminal-options' import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { ENABLE_WEBGL_RENDERER, @@ -130,6 +131,7 @@ export function createPaneDOM( container, xtermContainer, linkTooltip, + terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity, terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto', gpuRenderingEnabled: ENABLE_WEBGL_RENDERER, webglAttachmentDeferred: false, @@ -179,6 +181,7 @@ export function openTerminal(pane: ManagedPaneInternal): void { terminal, xtermContainer, linkTooltip, + terminalTuiScrollSensitivity, fitAddon, searchAddon, serializeAddon, @@ -197,6 +200,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { terminal.loadAddon(serializeAddon) terminal.loadAddon(unicode11Addon) terminal.loadAddon(webLinksAddon) + attachTerminalMouseWheelMultiplier(terminal, { + getTuiMouseWheelMultiplier: terminalTuiScrollSensitivity + }) // Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji / // ZWJ codepoints get baked into the buffer at the active unicode version on diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 6213b32003b..fb95e66b524 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -38,6 +38,7 @@ export type PaneManagerOptions = { * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void terminalOptions?: (paneId: number) => Partial + terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void initialRenderingSuspended?: boolean terminalGpuAcceleration?: GlobalSettings['terminalGpuAcceleration'] @@ -104,6 +105,7 @@ export type ScrollState = { export type ManagedPaneInternal = { xtermContainer: HTMLElement linkTooltip: HTMLElement + terminalTuiScrollSensitivity?: () => number | undefined terminalGpuAcceleration: GlobalSettings['terminalGpuAcceleration'] gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts new file mode 100644 index 00000000000..5ced0c616d0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + normalizeTerminalTuiMouseWheelMultiplier, + shouldMultiplyTerminalMouseWheel +} from './pane-terminal-mouse-wheel' + +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 + +function terminalElement(mouseReporting = true): HTMLElement { + return { + classList: { + contains: (className: string) => mouseReporting && className === 'enable-mouse-events' + } + } as HTMLElement +} + +function wheelEvent(init: Partial = {}): WheelEvent { + return { + deltaY: 100, + deltaMode: DOM_DELTA_PIXEL, + ...init + } as WheelEvent +} + +describe('terminal mouse wheel multiplier', () => { + it('uses a three-report multiplier for TUI mouse wheel scrolling', () => { + expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(3) + }) + + it('normalizes TUI wheel multipliers to the supported report range', () => { + expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(3) + expect(normalizeTerminalTuiMouseWheelMultiplier(0)).toBe(1) + expect(normalizeTerminalTuiMouseWheelMultiplier(4.4)).toBe(4) + expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) + }) + + it('multiplies discrete wheel events when mouse reporting is active', () => { + expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) + }) + + it('leaves normal terminal scrollback alone', () => { + expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) + }) + + it('leaves trackpad-like pixel scrolling one-to-one', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 12, + deltaMode: DOM_DELTA_PIXEL + }), + terminalElement() + ) + ).toBe(false) + }) + + it('multiplies non-pixel wheel deltas as discrete input', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 1, + deltaMode: DOM_DELTA_LINE + }), + terminalElement() + ) + ).toBe(true) + }) + + it('ignores horizontal shift-wheel events', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + shiftKey: true + }), + terminalElement() + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts new file mode 100644 index 00000000000..6e3ca37e996 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -0,0 +1,124 @@ +import type { Terminal } from '@xterm/xterm' + +const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' +const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' +const DOM_DELTA_PIXEL = 0 + +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 3 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 + +type TerminalWheelTarget = Pick + +type TerminalMouseWheelMultiplierOptions = { + getTuiMouseWheelMultiplier?: () => number | undefined +} + +type ReplayedWheelEvent = WheelEvent & { + [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean +} + +function isReplayedWheelEvent(event: WheelEvent): boolean { + return (event as ReplayedWheelEvent)[REPLAYED_WHEEL_EVENT_PROPERTY] === true +} + +function markReplayedWheelEvent(event: WheelEvent): void { + Object.defineProperty(event, REPLAYED_WHEEL_EVENT_PROPERTY, { + configurable: true, + value: true + }) +} + +function isDiscreteWheelEvent(event: WheelEvent): boolean { + if (event.deltaMode !== DOM_DELTA_PIXEL) { + return true + } + + return Math.abs(event.deltaY) >= 50 +} + +export function shouldMultiplyTerminalMouseWheel( + event: WheelEvent, + terminalElement: HTMLElement | null | undefined +): boolean { + if ( + isReplayedWheelEvent(event) || + !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || + event.deltaY === 0 || + event.shiftKey || + !isDiscreteWheelEvent(event) + ) { + return false + } + + return true +} + +function cloneWheelEvent(event: WheelEvent): WheelEvent { + const clone = new WheelEvent(event.type, { + bubbles: event.bubbles, + cancelable: event.cancelable, + composed: event.composed, + view: event.view, + detail: event.detail, + screenX: event.screenX, + screenY: event.screenY, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + button: event.button, + buttons: event.buttons, + relatedTarget: event.relatedTarget, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode + }) + markReplayedWheelEvent(clone) + return clone +} + +export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER + } + return Math.round( + Math.min( + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) + ) + ) +} + +export function attachTerminalMouseWheelMultiplier( + terminal: TerminalWheelTarget, + options: TerminalMouseWheelMultiplierOptions = {} +): void { + terminal.attachCustomWheelEventHandler((event) => { + if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { + return true + } + + const target = + event.currentTarget instanceof EventTarget ? event.currentTarget : terminal.element + if (!target) { + return true + } + + // Why: mouse-reporting TUIs receive wheel input as reports, not viewport + // scrollback, so normal xterm scrollSensitivity cannot tune their speed. + queueMicrotask(() => { + const multiplier = normalizeTerminalTuiMouseWheelMultiplier( + options.getTuiMouseWheelMultiplier?.() + ) + for (let i = 1; i < multiplier; i++) { + target.dispatchEvent(cloneWheelEvent(event)) + } + }) + + return true + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 82418eaff4a..8c154468331 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -3,6 +3,21 @@ import type { ITerminalOptions } from '@xterm/xterm' type TerminalCursorStyle = NonNullable type TerminalCursorInactiveStyle = NonNullable +export const DEFAULT_TERMINAL_SCROLL_SENSITIVITY = 1.15 +export const DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY = 5 + +export function normalizeTerminalScrollSensitivity(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(10, Math.max(0.1, value)) + : DEFAULT_TERMINAL_SCROLL_SENSITIVITY +} + +export function normalizeTerminalFastScrollSensitivity(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(20, Math.max(1, value)) + : DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY +} + export function resolveTerminalCursorInactiveStyle( cursorStyle: TerminalCursorStyle | undefined ): TerminalCursorInactiveStyle { @@ -26,6 +41,10 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { fontWeight: '300', fontWeightBold: '500', scrollback: 10000, + // Why: Orca's default terminal cells are taller than many users' baseline + // terminal, so a small multiplier keeps row-per-wheel movement familiar. + scrollSensitivity: DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + fastScrollSensitivity: DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, allowTransparency: false, // Why: agent CLIs sometimes render body text with ANSI white/bright-white // on light themes; xterm can keep those cells readable across renderers. diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6a57d5fb3dd..5c82398ff9a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -210,6 +210,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalFontFamily: defaultTerminalFontFamily(), terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, // Why: "auto" should use WebGL when supported while keeping DOM fallback // for renderer failures and Linux software/unknown GPU renderers. terminalGpuAcceleration: 'auto', diff --git a/src/shared/types.ts b/src/shared/types.ts index 1ddd0af588e..20db64abc6c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2391,6 +2391,9 @@ export type GlobalSettings = { terminalFontFamily: string terminalFontWeight: number terminalLineHeight: number + terminalScrollSensitivity: number + terminalFastScrollSensitivity: number + terminalTuiScrollSensitivity: number /** Terminal renderer policy. * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. * - 'on': always try xterm WebGL. From c767588e4291c6e159223bd16d0c854bc237dec4 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Mon, 22 Jun 2026 18:01:11 -0700 Subject: [PATCH 09/65] Load all hosts' repos at startup so cold start doesn't hide local repos (#6079) * Load all hosts' repos at startup so cold start doesn't hide local repos On a cold start, the renderer restores the last-active workspace. If that workspace lived on a remote runtime environment, settingsForExecutionHostOwner re-activates that environment, and startup's fetchRepos() then loads only the active host's repos. The result: every local repo disappears from the sidebar (even under the "All hosts" scope, which can only filter already-loaded repos), and adding a local folder fails with "Not a valid git repository" because the path is validated against the remote host. This reproduces on any cold start regardless of launcher; warm activations skip restore and appear unaffected. Add fetchReposForAllHosts(), which loads the local host plus every configured runtime environment and merges them into the unified repos array (each host fails soft so an unreachable/disconnected host is skipped without blocking the others). Call it at startup instead of fetchRepos() so the sidebar shows local + remote together no matter which environment is active. Co-Authored-By: Claude Opus 4.8 (1M context) * Schedule safe-auto fork sync per host in fetchReposForAllHosts Codex review caught that the new all-host startup fetch dropped the scheduleSafeAutoForkSync side effect that both fetchRepos and fetchRuntimeEnvironmentRepos run after merging a host. Without it, repos with forkSyncMode 'safe-auto' would stop syncing on cold start. Schedule the sync for each fetched host's repos after applying its result. Co-Authored-By: Claude Opus 4.8 (1M context) * Load startup project data across all hosts Co-authored-by: Orca --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/renderer/src/App.tsx | 12 +- src/renderer/src/app-startup-routing.test.ts | 2 +- src/renderer/src/hooks/useIpcEvents.ts | 13 +- .../src/store/slices/repos-all-hosts.test.ts | 270 +++++++++++++ src/renderer/src/store/slices/repos.ts | 365 ++++++++++++++++-- 5 files changed, 618 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/store/slices/repos-all-hosts.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e9938d3861a..aacb0a5c9e5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -388,8 +388,11 @@ function App(): React.JSX.Element { useShallow((s) => ({ toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, + fetchReposForAllHosts: s.fetchReposForAllHosts, fetchProjectGroups: s.fetchProjectGroups, + fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, fetchFolderWorkspaces: s.fetchFolderWorkspaces, + fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, @@ -840,9 +843,12 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await actions.fetchSettings() - await actions.fetchRepos() - await actions.fetchProjectGroups() - await actions.fetchFolderWorkspaces() + // Why: load local + every configured runtime environment (not just the + // active one) so a cold start that restored a remote workspace doesn't + // hide local repos. The sidebar "All hosts" scope then shows them all. + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() await actions.fetchAllWorktrees() await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fa5802dbd57..0320b41b796 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -11,7 +11,7 @@ describe('renderer startup runtime routing', () => { expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeGreaterThanOrEqual(0) expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( - startupBlock.indexOf('await actions.fetchRepos()') + startupBlock.indexOf('await actions.fetchReposForAllHosts()') ) expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( startupBlock.indexOf('await actions.fetchAllWorktrees()') diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b7b64771ebe..d19cea07d4e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1028,13 +1028,18 @@ export function useIpcEvents(): void { unsubs.push( window.api.repos.onChanged(() => { + const state = useAppStore.getState() if (isRuntimeEnvironmentActive()) { - // Why: this event comes from the local Electron store. While a - // runtime server is selected, repo hydration must be driven by the - // selected server instead of local-disk changes. + // Why: the all-host sidebar includes local repos even when a runtime + // is focused, so local store changes must refresh the local slice + // without dropping the runtime-owned slices already shown. + void (async () => { + await state.fetchReposForAllHosts() + await state.fetchProjectGroupsForAllHosts() + await state.fetchFolderWorkspacesForAllHosts() + })() return } - const state = useAppStore.getState() void state.fetchProjectGroups() void state.fetchFolderWorkspaces() void state.fetchRepos() diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts new file mode 100644 index 00000000000..902f808fc7f --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + FolderWorkspace, + Project, + ProjectHostSetup, + ProjectGroup, + Repo +} from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1 +} + +const localProject: Project = { + id: 'local-project', + displayName: 'Local project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const localProjectHostSetup: ProjectHostSetup = { + id: 'local-setup', + projectId: 'local-project', + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local setup', + setupState: 'setting-up', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 +} + +const localProjectGroup: ProjectGroup = { + id: 'local-group', + name: 'Local group', + parentPath: '/local', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const remoteProjectGroup: ProjectGroup = { + id: 'remote-group', + name: 'Remote group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const localFolderWorkspace: FolderWorkspace = { + id: 'local-folder', + projectGroupId: 'local-group', + name: 'Local folder', + folderPath: '/local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const remoteFolderWorkspace: FolderWorkspace = { + id: 'remote-folder', + projectGroupId: 'remote-group', + name: 'Remote folder', + folderPath: '/srv', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const projectGroupsList = vi.fn() +const folderWorkspacesList = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const dispatchEventMock = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + projectGroupsList.mockReset() + folderWorkspacesList.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + + reposList.mockResolvedValue([localRepo]) + projectsList.mockResolvedValue([localProject]) + listHostSetups.mockResolvedValue([localProjectHostSetup]) + projectGroupsList.mockResolvedValue([localProjectGroup]) + folderWorkspacesList.mockResolvedValue([localFolderWorkspace]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectGroup.list') { + return { + id: 'rpc-project-group-list', + ok: true, + result: { groups: [remoteProjectGroup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'folderWorkspace.list') { + return { + id: 'rpc-folder-workspace-list', + ok: true, + result: { folderWorkspaces: [remoteFolderWorkspace] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups: listHostSetups }, + projectGroups: { list: projectGroupsList }, + folderWorkspaces: { list: folderWorkspacesList }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: dispatchEventMock + }) +}) + +describe('fetchReposForAllHosts', () => { + it('loads local + all configured runtime environments even when a remote env is active', async () => { + // Why: a cold start that restored a remote workspace leaves the remote + // environment active. The active-host-only fetchRepos would drop local + // repos entirely; fetchReposForAllHosts must surface both. + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + const ids = store + .getState() + .repos.map((repo) => repo.id) + .sort() + expect(ids).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects).toContainEqual(localProject) + expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) + }) + + it('fails soft when a runtime environment is unreachable, keeping local repos', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + }) + + it('loads project groups and folder workspaces for every host', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' }, + { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'local-folder', + 'remote-folder' + ]) + }) + + it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { repos: [], projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace]) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6b99742955b..a8a55aeb60d 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -417,6 +417,56 @@ function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { return `${setup.hostId}:${setup.repoId ?? setup.id}` } +function getProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] +): Set { + const hostIds = new Set() + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + for (const setup of setups) { + if (setup.projectId === project.id) { + hostIds.add(setup.hostId) + } + } + for (const repoId of project.sourceRepoIds) { + const repo = repoById.get(repoId) + if (repo) { + hostIds.add(getRepoExecutionHostId(repo)) + } + } + if (hostIds.size === 0) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } + return hostIds +} + +function mergeFetchedProjectCompatibilityForHost({ + previous, + fetched, + repos, + hostId +}: { + previous: Pick + fetched: Pick + repos: readonly Repo[] + hostId: string +}): Pick { + const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) + const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) + const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) + const fetchedProjectsForHost = fetched.projects.filter((project) => + getProjectHostIds(project, fetched.projectHostSetups, repos).has(hostId) + ) + const preservedProjects = previous.projects.filter( + (project) => !getProjectHostIds(project, previous.projectHostSetups, repos).has(hostId) + ) + return { + projects: mergeById(preservedProjects, fetchedProjectsForHost), + projectHostSetups + } +} + function mergeById(base: readonly T[], overlay: readonly T[]): T[] { const merged = [...base] const indexById = new Map(merged.map((entry, index) => [entry.id, index])) @@ -458,6 +508,48 @@ function mergeFetchedReposForHost( ) } +function getProjectGroupHostId(group: Pick) { + if (group.executionHostId) { + return group.executionHostId + } + return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID +} + +function mergeFetchedProjectGroupsForHost( + previous: readonly ProjectGroup[], + fetched: ProjectGroup[], + hostId: string +): ProjectGroup[] { + const fetchedIds = new Set(fetched.map((group) => group.id)) + const preserved = previous.filter((group) => { + const existingHostId = getProjectGroupHostId(group) + return existingHostId !== hostId || fetchedIds.has(group.id) + }) + return mergeById(preserved, fetched) +} + +function mergeFetchedFolderWorkspacesForHost({ + previous, + fetched, + projectGroups, + hostId +}: { + previous: readonly FolderWorkspace[] + fetched: FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + hostId: string +}): FolderWorkspace[] { + const fetchedIds = new Set(fetched.map((workspace) => workspace.id)) + const projectGroupHostIds = new Map( + projectGroups.map((group) => [group.id, getProjectGroupHostId(group)]) + ) + const preserved = previous.filter((workspace) => { + const existingHostId = projectGroupHostIds.get(workspace.projectGroupId) + return existingHostId === undefined || existingHostId !== hostId || fetchedIds.has(workspace.id) + }) + return mergeById(preserved, fetched) +} + async function fetchReposForTarget( target: ReturnType, currentRepos: readonly Repo[] @@ -492,6 +584,66 @@ async function fetchReposForTarget( return { repos: reconciledRepos, projectCompatibility, hostId } } +async function fetchProjectGroupsForTarget( + target: ReturnType, + currentProjectGroups: readonly ProjectGroup[] +): Promise<{ projectGroups: ProjectGroup[]; hostId: ReturnType }> { + const fetchedGroups = + target.kind === 'local' + ? await window.api.projectGroups.list() + : ( + await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { + timeoutMs: 15_000 + }) + ).groups + const hostId = getRuntimeTargetHostId(target) + const ownedGroups = fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)) + return { + projectGroups: mergeFetchedProjectGroupsForHost(currentProjectGroups, ownedGroups, hostId), + hostId + } +} + +async function fetchFolderWorkspacesForTarget( + target: ReturnType, + currentFolderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): Promise<{ + folderWorkspaces: FolderWorkspace[] + hostId: ReturnType +}> { + const fetchedFolderWorkspaces = + target.kind === 'local' + ? await window.api.folderWorkspaces.list() + : ( + await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( + target, + 'folderWorkspace.list', + undefined, + { timeoutMs: 15_000 } + ) + ).folderWorkspaces + const hostId = getRuntimeTargetHostId(target) + return { + folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ + previous: currentFolderWorkspaces, + fetched: fetchedFolderWorkspaces, + projectGroups, + hostId + }), + hostId + } +} + +async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> { + try { + return (await window.api.runtimeEnvironments.list()) ?? [] + } catch (err) { + console.warn('Failed to list runtime environments for all-host load:', err) + return [] + } +} + function settingsForRepoOwner(state: Pick, repoId: string) { return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] } @@ -625,9 +777,12 @@ export type RepoSlice = { folderWorkspacePathStatuses: Record activeRepoId: string | null fetchRepos: () => Promise + fetchReposForAllHosts: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise fetchProjectGroups: () => Promise + fetchProjectGroupsForAllHosts: () => Promise fetchFolderWorkspaces: () => Promise + fetchFolderWorkspacesForAllHosts: () => Promise addRepo: () => Promise addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise setupProjectExistingFolder: ( @@ -746,9 +901,18 @@ export const createRepoSlice: StateCreator = (set, } = await fetchReposForTarget(target, get().repos) set((s) => { const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: projectCompatibility, + repos: reconciledRepos, + hostId + }) return { repos: reconciledRepos, - ...projectCompatibility, + ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -776,16 +940,27 @@ export const createRepoSlice: StateCreator = (set, hostId } = await fetchReposForTarget(target, get().repos) const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) - set((s) => ({ - repos: reconciledRepos, - ...projectCompatibility, - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoIds - ) - })) + set((s) => { + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: projectCompatibility, + repos: reconciledRepos, + hostId + }) + return { + repos: reconciledRepos, + ...mergedProjectCompatibility, + activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoIds + ) + } + }) const fetchedHostRepos = reconciledRepos.filter( (repo) => getRepoExecutionHostId(repo) === hostId ) @@ -797,24 +972,77 @@ export const createRepoSlice: StateCreator = (set, } }, + fetchReposForAllHosts: async () => { + // Why: a cold start that restores a remote workspace re-activates that + // remote runtime environment, and fetching only the active host hides every + // other host's repos (notably all local repos), which reads as "my projects + // vanished". Load local + every configured runtime environment so the + // sidebar "All hosts" scope shows them together regardless of which + // environment is active. Each host fails soft: an unreachable/disconnected + // host is skipped without blocking the others. + const applyResult = (result: Awaited>): void => { + const validRepoIds = new Set(result.repos.map((repo) => repo.id)) + set((s) => { + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: result.projectCompatibility, + repos: result.repos, + hostId: result.hostId + }) + return { + repos: result.repos, + ...mergedProjectCompatibility, + folderWorkspacePathStatuses: {}, + activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoIds + ) + } + }) + // Why: preserve the safe-auto fork sync that fetchRepos / + // fetchRuntimeEnvironmentRepos schedule after merging each host, so + // cold-start (which now routes through here) keeps updating safe-auto forks. + scheduleSafeAutoForkSync( + get, + result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) + ) + } + + // Local first so local repos are present even if a remote fetch stalls. + try { + applyResult(await fetchReposForTarget({ kind: 'local' }, get().repos)) + } catch (err) { + console.error('Failed to fetch local repos for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + + // Sequential to avoid concurrent set() races on the merged repos array. + for (const environment of environments) { + try { + applyResult( + await fetchReposForTarget( + { kind: 'environment', environmentId: environment.id }, + get().repos + ) + ) + } catch (err) { + console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) + } + } + }, + fetchProjectGroups: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const projectGroups = - target.kind === 'local' - ? await window.api.projectGroups.list() - : ( - await callRuntimeRpc<{ groups: ProjectGroup[] }>( - target, - 'projectGroup.list', - undefined, - { - timeoutMs: 15_000 - } - ) - ).groups + const { projectGroups } = await fetchProjectGroupsForTarget(target, []) set({ - projectGroups: projectGroups.map((group) => projectGroupWithFetchedOwner(group, target)), + projectGroups, folderWorkspacePathStatuses: {} }) } catch (err) { @@ -822,26 +1050,91 @@ export const createRepoSlice: StateCreator = (set, } }, + fetchProjectGroupsForAllHosts: async () => { + // Why: startup renders an all-host sidebar; replacing groups with only the + // active host would leave repos from other hosts visible but ungrouped. + const applyResult = (result: Awaited>): void => { + set({ + projectGroups: result.projectGroups, + folderWorkspacePathStatuses: {} + }) + } + + try { + applyResult(await fetchProjectGroupsForTarget({ kind: 'local' }, get().projectGroups)) + } catch (err) { + console.error('Failed to fetch local project groups for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + for (const environment of environments) { + try { + applyResult( + await fetchProjectGroupsForTarget( + { kind: 'environment', environmentId: environment.id }, + get().projectGroups + ) + ) + } catch (err) { + console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) + } + } + }, + fetchFolderWorkspaces: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const folderWorkspaces = - target.kind === 'local' - ? await window.api.folderWorkspaces.list() - : ( - await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( - target, - 'folderWorkspace.list', - undefined, - { timeoutMs: 15_000 } - ) - ).folderWorkspaces + const { folderWorkspaces } = await fetchFolderWorkspacesForTarget( + target, + [], + get().projectGroups + ) set({ folderWorkspaces, folderWorkspacePathStatuses: {} }) } catch (err) { console.error('Failed to fetch folder workspaces:', err) } }, + fetchFolderWorkspacesForAllHosts: async () => { + // Why: folder workspaces are owned through their project groups, so startup + // must fetch groups first and then merge each host's folder slice. + const applyResult = ( + result: Awaited> + ): void => { + set({ + folderWorkspaces: result.folderWorkspaces, + folderWorkspacePathStatuses: {} + }) + } + + try { + applyResult( + await fetchFolderWorkspacesForTarget( + { kind: 'local' }, + get().folderWorkspaces, + get().projectGroups + ) + ) + } catch (err) { + console.error('Failed to fetch local folder workspaces for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + for (const environment of environments) { + try { + applyResult( + await fetchFolderWorkspacesForTarget( + { kind: 'environment', environmentId: environment.id }, + get().folderWorkspaces, + get().projectGroups + ) + ) + } catch (err) { + console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) + } + } + }, + getFolderWorkspacePathStatusCacheKey: (request, options) => `${getRuntimeTargetCachePrefix( getFolderWorkspacePathStatusRouteSettings(options, get().settings) From ae3d088e5e0ce31a888e714e916d19680b82920b Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 22 Jun 2026 18:01:37 -0700 Subject: [PATCH 10/65] fix: Persist custom macOS dock icon (#6088) * Persist custom macOS dock icon * Warn on dock icon metadata clear failures * Harden macOS dock icon persistence Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/app-icon.test.ts | 376 +++++++++++++++++++++++++++++++++++++- src/main/app-icon.ts | 268 +++++++++++++++++++++++++++ 2 files changed, 642 insertions(+), 2 deletions(-) diff --git a/src/main/app-icon.test.ts b/src/main/app-icon.test.ts index ccef8667279..520fcbeb150 100644 --- a/src/main/app-icon.test.ts +++ b/src/main/app-icon.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { browserWindowGetAllWindowsMock, @@ -36,11 +37,37 @@ vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({ default: 'watercolor-icon' })) +vi.mock('../../resources/app-icons/orca-watercolor.png?asset&asarUnpack', () => ({ + default: 'watercolor-icon-unpacked' +})) + vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({ default: 'blue-icon' })) -import { applyAppIcon, getAppIconPath } from './app-icon' +vi.mock('../../resources/app-icons/orca-blue.png?asset&asarUnpack', () => ({ + default: 'blue-icon-unpacked' +})) + +import { applyAppIcon, getAppIconPath, persistMacDockIcon } from './app-icon' + +function waitForQueuedPersistence(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +async function waitForQueuedPersistenceMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function createMockChildProcess(): EventEmitter & { kill: ReturnType } { + const childProcess = new EventEmitter() as EventEmitter & { kill: ReturnType } + childProcess.kill = vi.fn(() => { + childProcess.emit('exit') + return true + }) + return childProcess +} describe('app icon selection', () => { beforeEach(() => { @@ -51,6 +78,10 @@ describe('app icon selection', () => { isMock.dev = false }) + afterEach(() => { + vi.useRealTimers() + }) + it('resolves classic, watercolor, blue, and invalid icon ids', () => { expect(getAppIconPath('classic')).toBe('classic-icon') expect(getAppIconPath('watercolor')).toBe('watercolor-icon') @@ -76,4 +107,345 @@ describe('app icon selection', () => { } expect(windowSetIconMock).toHaveBeenCalledWith(image) }) + + it('persists a custom macOS dock icon to the app bundle for inactive Dock pins', async () => { + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.arrayContaining(['-e', expect.stringContaining('setIcon:image forFile:appPath')]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app', + ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked' + }) + }), + expect.any(Function) + ) + }) + + it('clears the AppKit icon and Finder metadata when switching macOS back to classic', async () => { + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenNthCalledWith( + 1, + '/usr/bin/osascript', + expect.arrayContaining([ + '-e', + expect.stringContaining('setIcon:(missing value) forFile:appPath') + ]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/xattr', + ['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/xattr', + ['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + }) + + it('warns for non-benign failures when clearing Finder custom icon metadata', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const execFile = vi.fn( + ( + file: string, + args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + if (file !== '/usr/bin/xattr') { + onComplete?.(null) + return + } + onComplete?.(new Error(args[1] === 'com.apple.FinderInfo' ? 'No such xattr' : 'EACCES')) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[app-icon] failed to clear macOS dock icon metadata com.apple.ResourceFork:', + expect.any(Error) + ) + + warnSpy.mockRestore() + }) + + it('warns when the AppKit classic icon reset fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const execFile = vi.fn( + ( + file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(file === '/usr/bin/osascript' ? new Error('reset denied') : null) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(warnSpy).toHaveBeenCalledWith( + '[app-icon] failed to clear macOS dock icon:', + expect.any(Error) + ) + + warnSpy.mockRestore() + }) + + it('serializes rapid macOS dock icon persistence so the last icon request wins', async () => { + const pendingCallbacks: (() => void)[] = [] + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + pendingCallbacks.push(() => onComplete?.(null)) + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + persistMacDockIcon('blue', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + + expect(execFile).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked' + }) + }), + expect.any(Function) + ) + + pendingCallbacks.shift()?.() + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledTimes(2) + expect(execFile).not.toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'blue-icon-unpacked' + }) + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenNthCalledWith( + 2, + '/usr/bin/osascript', + expect.arrayContaining([ + '-e', + expect.stringContaining('setIcon:(missing value) forFile:appPath') + ]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + pendingCallbacks.shift()?.() + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledTimes(4) + expect(execFile).toHaveBeenNthCalledWith( + 3, + '/usr/bin/xattr', + ['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenNthCalledWith( + 4, + '/usr/bin/xattr', + ['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + + for (const completeCommand of pendingCallbacks) { + completeCommand() + } + await waitForQueuedPersistence() + }) + + it('continues macOS dock icon persistence when a command never completes', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hungChildProcess = createMockChildProcess() + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + if (execFile.mock.calls.length === 1) { + return hungChildProcess + } + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + return undefined + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistenceMicrotasks() + + persistMacDockIcon('blue', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + + expect(execFile).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10_000) + await waitForQueuedPersistenceMicrotasks() + + expect(hungChildProcess.kill).not.toHaveBeenCalled() + expect(execFile).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + await waitForQueuedPersistenceMicrotasks() + + expect(warnSpy).toHaveBeenCalledWith('[app-icon] timed out persisting macOS dock icon') + expect(hungChildProcess.kill).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledTimes(2) + expect(execFile).toHaveBeenNthCalledWith( + 2, + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'blue-icon-unpacked' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + + warnSpy.mockRestore() + }) }) diff --git a/src/main/app-icon.ts b/src/main/app-icon.ts index aa52133c6dc..8964b9eeb50 100644 --- a/src/main/app-icon.ts +++ b/src/main/app-icon.ts @@ -1,9 +1,17 @@ +import { + execFile as execFileChildProcess, + type ChildProcess, + type ExecFileOptions +} from 'node:child_process' +import { dirname, resolve } from 'node:path' import { app, BrowserWindow, nativeImage } from 'electron' import { is } from '@electron-toolkit/utils' import classicIcon from '../../resources/icon.png?asset' import classicDevIcon from '../../resources/icon-dev.png?asset' import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset' +import watercolorMacDockIcon from '../../resources/app-icons/orca-watercolor.png?asset&asarUnpack' import blueIcon from '../../resources/app-icons/orca-blue.png?asset' +import blueMacDockIcon from '../../resources/app-icons/orca-blue.png?asset&asarUnpack' import { normalizeAppIconId, type AppIconId } from '../shared/app-icon' const APP_ICON_PATHS = { @@ -12,6 +20,59 @@ const APP_ICON_PATHS = { blue: blueIcon } satisfies Record +const MAC_DOCK_ICON_PATHS = { + watercolor: watercolorMacDockIcon, + blue: blueMacDockIcon +} satisfies Record, string> + +type ExecFile = ( + file: string, + args: string[], + optionsOrCallback: ExecFileOptions | ((error: Error | null) => void), + callback?: (error: Error | null) => void +) => unknown + +type MacDockIconChildProcess = Pick + +type PersistMacDockIconOptions = { + appBundlePath?: string + execFile?: ExecFile + isDevApp?: boolean + platform?: NodeJS.Platform +} + +const MAC_DOCK_ICON_SCRIPT = [ + 'use framework "AppKit"', + 'use scripting additions', + 'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"', + 'set iconPath to system attribute "ORCA_APP_ICON_PATH"', + "set image to current application's NSImage's alloc()'s initWithContentsOfFile:iconPath", + 'if image is missing value then error "Orca app icon image could not be loaded"', + "set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:image forFile:appPath options:0", + 'if ok is false then error "Orca app icon could not be persisted"' +] + +const MAC_DOCK_ICON_CLEAR_SCRIPT = [ + 'use framework "AppKit"', + 'use scripting additions', + 'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"', + "set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:(missing value) forFile:appPath options:0", + 'if ok is false then error "Orca app icon could not be cleared"' +] + +const MAC_DOCK_ICON_COMMAND_TIMEOUT_MS = 10_000 +const MAC_DOCK_ICON_COMMAND_FALLBACK_MS = 1_000 + +const defaultExecFile: ExecFile = (file, args, optionsOrCallback, callback) => { + if (typeof optionsOrCallback === 'function') { + return execFileChildProcess(file, args, optionsOrCallback) + } + return execFileChildProcess(file, args, optionsOrCallback, callback ?? (() => {})) +} + +let macDockIconPersistenceGeneration = 0 +let macDockIconPersistenceQueue = Promise.resolve() + export function getAppIconPath(value: unknown): string { return APP_ICON_PATHS[normalizeAppIconId(value)] } @@ -20,6 +81,212 @@ export function createAppIconImage(value: unknown): Electron.NativeImage { return nativeImage.createFromPath(getAppIconPath(value)) } +function getMacAppBundlePath(): string | undefined { + const appBundlePath = resolve(dirname(app.getPath('exe')), '..', '..') + return appBundlePath.endsWith('.app') ? appBundlePath : undefined +} + +function runMacCustomIconCommand( + execFile: ExecFile, + appBundlePath: string, + iconPath: string +): Promise { + return runBoundedMacDockIconCommand({ + args: MAC_DOCK_ICON_SCRIPT.flatMap((line) => ['-e', line]), + execFile, + file: '/usr/bin/osascript', + onError: (error) => { + console.warn('[app-icon] failed to persist macOS dock icon:', error) + }, + options: { + env: { + ...process.env, + ORCA_APP_BUNDLE_PATH: appBundlePath, + ORCA_APP_ICON_PATH: iconPath + } + }, + timeoutWarning: '[app-icon] timed out persisting macOS dock icon' + }) +} + +type BoundedMacDockIconCommandOptions = { + args: string[] + execFile: ExecFile + file: string + onError: (error: Error) => void + options?: ExecFileOptions + timeoutWarning: string +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function isMacDockIconChildProcess(value: unknown): value is MacDockIconChildProcess { + return ( + typeof value === 'object' && + value !== null && + 'kill' in value && + typeof value.kill === 'function' && + 'once' in value && + typeof value.once === 'function' + ) +} + +function runBoundedMacDockIconCommand({ + args, + execFile, + file, + onError, + options, + timeoutWarning +}: BoundedMacDockIconCommandOptions): Promise { + return new Promise((resolve) => { + let childProcess: MacDockIconChildProcess | undefined + let settled = false + let forceFallback: NodeJS.Timeout | undefined + + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(fallback) + if (forceFallback) { + clearTimeout(forceFallback) + } + if (error) { + onError(error) + } + resolve() + } + + const finishAfterFallback = (): void => { + if (settled) { + return + } + settled = true + if (forceFallback) { + clearTimeout(forceFallback) + } + console.warn(timeoutWarning) + resolve() + } + + const fallback = setTimeout(() => { + if (settled) { + return + } + if (!childProcess) { + finishAfterFallback() + return + } + // Why: the queue must not release while an older icon process can still win. + childProcess.once('exit', finishAfterFallback) + childProcess.once('close', finishAfterFallback) + forceFallback = setTimeout(finishAfterFallback, MAC_DOCK_ICON_COMMAND_FALLBACK_MS) + childProcess.kill() + }, MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + MAC_DOCK_ICON_COMMAND_FALLBACK_MS) + + try { + const maybeChildProcess = execFile( + file, + args, + { + ...options, + timeout: MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + }, + finish + ) + if (isMacDockIconChildProcess(maybeChildProcess)) { + childProcess = maybeChildProcess + } + } catch (error) { + finish(toError(error)) + } + }) +} + +function handleMacDockIconQueueError(error: unknown): void { + console.warn('[app-icon] failed to persist macOS dock icon:', error) +} + +function enqueueMacDockIconPersistence(work: () => Promise): void { + macDockIconPersistenceQueue = macDockIconPersistenceQueue + .catch(handleMacDockIconQueueError) + .then(work) + .catch(handleMacDockIconQueueError) +} + +function clearMacCustomIconMetadata(execFile: ExecFile, appBundlePath: string): Promise { + const clearAppKitIcon = (): Promise => { + return runBoundedMacDockIconCommand({ + args: MAC_DOCK_ICON_CLEAR_SCRIPT.flatMap((line) => ['-e', line]), + execFile, + file: '/usr/bin/osascript', + onError: (error) => { + console.warn('[app-icon] failed to clear macOS dock icon:', error) + }, + options: { + env: { + ...process.env, + ORCA_APP_BUNDLE_PATH: appBundlePath + } + }, + timeoutWarning: '[app-icon] timed out clearing macOS dock icon' + }) + } + + const clearAttribute = (attribute: string): Promise => { + return runBoundedMacDockIconCommand({ + args: ['-d', attribute, appBundlePath], + execFile, + file: '/usr/bin/xattr', + onError: (error) => { + if (!error.message.includes('No such xattr')) { + console.warn(`[app-icon] failed to clear macOS dock icon metadata ${attribute}:`, error) + } + }, + timeoutWarning: `[app-icon] timed out clearing macOS dock icon metadata ${attribute}` + }) + } + + return clearAppKitIcon().then(() => + Promise.all([ + clearAttribute('com.apple.FinderInfo'), + clearAttribute('com.apple.ResourceFork') + ]).then(() => {}) + ) +} + +export function persistMacDockIcon(value: unknown, options: PersistMacDockIconOptions = {}): void { + const platform = options.platform ?? process.platform + const isDevApp = options.isDevApp ?? (is.dev || !app.isPackaged) + if (platform !== 'darwin' || isDevApp) { + return + } + const appBundlePath = options.appBundlePath ?? getMacAppBundlePath() + if (!appBundlePath) { + return + } + const execFile = options.execFile ?? defaultExecFile + const iconId = normalizeAppIconId(value) + const generation = ++macDockIconPersistenceGeneration + enqueueMacDockIconPersistence(async () => { + // Why: stale queued writes must not reapply an older Dock pin icon. + if (generation !== macDockIconPersistenceGeneration) { + return + } + if (iconId === 'classic') { + await clearMacCustomIconMetadata(execFile, appBundlePath) + return + } + // Why: a stopped app's Dock tile is resolved from Finder metadata, not + // Electron's live app.dock.setIcon state. + await runMacCustomIconCommand(execFile, appBundlePath, MAC_DOCK_ICON_PATHS[iconId]) + }) +} + export function applyAppIcon(value: unknown): void { const image = createAppIconImage(value) if (image.isEmpty()) { @@ -33,4 +300,5 @@ export function applyAppIcon(value: unknown): void { window.setIcon(image) } } + persistMacDockIcon(value) } From 889ac6094a15f12415cded1af01d3e28875368e6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:09:08 -0700 Subject: [PATCH 11/65] Improve highlight contrast for selected file explorer rows (#6104) Apply styling via data attributes and custom CSS in main.css instead of basic utility classes. This provides a more distinct background color for selected files in both light and dark modes, and adds a subtle border to ensure legibility against panel backgrounds. --- src/renderer/src/assets/main.css | 20 +++++++++++++++++++ .../right-sidebar/FileExplorerRow.tsx | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d73f553139b..867ed6e958f 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -922,6 +922,26 @@ /* ── Sidebar ─────────────────────────────────────────── */ +[data-file-explorer-row][data-selected='true'] { + /* Why: flat bg-accent ≈ panel background in light mode, so selection mixes + foreground into accent and adds an inset ring to stay legible in both themes. */ + background: color-mix(in srgb, var(--foreground) 8%, var(--accent)); + box-shadow: inset 0 0 0 1px var(--border); + color: var(--accent-foreground); +} + +[data-file-explorer-row][data-selected='true']:hover { + background: color-mix(in srgb, var(--foreground) 10%, var(--accent)); +} + +.dark [data-file-explorer-row][data-selected='true'] { + background: color-mix(in srgb, var(--accent) 70%, transparent); +} + +.dark [data-file-explorer-row][data-selected='true']:hover { + background: color-mix(in srgb, var(--accent) 80%, transparent); +} + .sidebar { width: 240px; flex-shrink: 0; diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 757120d782a..8d9194056a4 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -462,9 +462,12 @@ export function FileExplorerRow({ >
) : null} - {/* Why: the repo combobox filters Items mode by repo. In - Project mode the row set comes from the project's - view filter (server-side), so this control would be - inert — hide it to avoid suggesting it does - something. */} - {githubMode !== 'project' && ( - <> -
- { - const normalized = normalizeTaskRepoSelection(eligibleRepos, next) - setRepoSelection(normalized) - void updateSettings({ defaultRepoSelection: [...normalized] }).catch( - () => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - } - ) - }} - onSelectAll={() => { - const allIds = new Set(taskPickerRepos.map((r) => r.id)) - setRepoSelection(allIds) - void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) + {/* Why: Project rows are now repo-scoped too, so the + selection must stay visible in both GitHub modes. */} +
+ { + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' ) - }) - }} - triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> -
- - - - - - {selectedGitHubRepoExternalLink + ) + } + ) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ defaultRepoSelection: null }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ + + + + + {selectedGitHubRepoExternalLink + ? translate( + 'auto.components.TaskPage.8d1e17a3ef', + 'Open {{value0}} in GitHub', + { value0: selectedGitHubRepoExternalLink.label } + ) + : translate( + 'auto.components.TaskPage.bc46d8204e', + 'Select one project to open in GitHub' + )} + +
) : null} @@ -8635,7 +8628,7 @@ export default function TaskPage(): React.JSX.Element { ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? (
diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index f76361d3654..705d306072c 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' import ProjectItemSlugDialog from './ProjectItemSlugDialog' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' import { resolveMissingRepoProjectDialogState, resolveRepoBackedProjectDialogState } from './project-dialog-state' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, getVisibleProjectTable, type CachedVisibleProjectTable } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' -type Props = Record +type Props = { + selectedRepoIds: ReadonlySet +} const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' @@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable) @@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J const table: GitHubProjectTable | null = currentCacheKey ? (projectViewCache[currentCacheKey]?.data ?? null) : null + const selectedRepoFingerprint = useMemo( + () => getSelectedRepoFingerprint(selectedRepoIds), + [selectedRepoIds] + ) const filteredTable = useMemo( - () => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null), - [table, slugIndexReady, lookupSlug] + () => + table && slugIndexReady + ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + : null, + [table, slugIndexReady, lookupSlug, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: this cache only prevents a blank table while the repo slug index @@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J // a second render after every filtered-table change. lastFilteredTableRef.current = getNextVisibleProjectTableCache({ currentCacheKey, + selectedRepoFingerprint, sourceTable: table, slugIndexReady, filteredTable, @@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) const visibleTable = getVisibleProjectTable({ currentCacheKey, + selectedRepoFingerprint, slugIndexReady, filteredTable, cachedTable: lastFilteredTableRef.current @@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } | null>(null) const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos]) - const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds) + const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState( + dialogRepoItem, + liveRepoIds, + selectedRepoIds + ) if (resolvedDialogRepoItem !== dialogRepoItem) { // Why: repo-backed Project dialogs cannot edit after their repo leaves // Orca; clear them before the modal tree receives stale repo ids. @@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J slugIndexReady, slugDialog, repoNotInOrca, - lookupSlug + lookupSlug, + selectedRepoIds }) if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) { // Why: once a previously missing repo is registered, Project rows should @@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J [] ) + const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => { + if (row.content.url) { + void window.api.shell.openUrl(row.content.url) + } + toast.message(message) + }, []) + const handleOpenDialog = useCallback( (row: GitHubProjectRow) => { if (!currentCacheKey || !table) { @@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (matched) { - const workItem = buildWorkItem(row, matched.id) + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'selected_match') { + const workItem = buildWorkItem(row, resolution.repo.id) if (workItem) { - setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin }) + setDialogRepoItem({ + workItem, + repoPath: resolution.repo.path, + repoId: resolution.repo.id, + origin + }) return } } - // Unknown repo — use the simplified slug-mode dialog. - setSlugDialog({ origin }) + if (resolution.status === 'no_global_match') { + // Unknown repo — use the simplified slug-mode dialog. + setSlugDialog({ origin }) + return + } + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + } }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleStartWork = useCallback( @@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!origin) { return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (!matched) { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'no_global_match') { setRepoNotInOrca({ owner: origin.owner, repo: origin.repo, @@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) return } - const workItem = buildWorkItem(row, matched.id) + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + return + } + if (resolution.status !== 'selected_match') { + return + } + const workItem = buildWorkItem(row, resolution.repo.id) if (!workItem) { return } void launchWorkItemDirect({ item: workItem, - repoId: matched.id, + repoId: resolution.repo.id, launchSource: 'task_page', telemetrySource: 'sidebar', openModalFallback: () => { @@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }) }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleEditAssignees = useCallback( diff --git a/src/renderer/src/components/github-project/project-dialog-state.test.ts b/src/renderer/src/components/github-project/project-dialog-state.test.ts index 78aa6b1274a..99afa238841 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.test.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.test.ts @@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => { it('keeps a repo-backed dialog when the repo still exists', () => { const dialog = { repoId: 'repo-1', label: 'Issue 1' } - expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog) + expect( + resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1'])) + ).toBe(dialog) }) it('clears a repo-backed dialog when its repo is removed', () => { expect( - resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2'])) + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-2']), + new Set(['repo-1']) + ) + ).toBeNull() + }) + + it('clears a repo-backed dialog when its repo is no longer selected', () => { + expect( + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-1']), + new Set(['repo-2']) + ) ).toBeNull() }) }) describe('resolveMissingRepoProjectDialogState', () => { - it('waits for the slug index before closing missing-repo dialogs', () => { + it('clears fallback dialogs while the slug index is rebuilding', () => { const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } @@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: false, slugDialog, repoNotInOrca, - lookupSlug: () => ['repo-1'] + lookupSlug: () => [{ id: 'repo-1' }], + selectedRepoIds: new Set(['repo-1']) }) - ).toEqual({ slugDialog, repoNotInOrca }) + ).toEqual({ slugDialog: null, repoNotInOrca: null }) }) it('clears slug fallback dialogs once the repo slug resolves', () => { @@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBeNull() @@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBe(slugDialog) expect(result.repoNotInOrca).toBeNull() }) + + it('clears fallback dialogs when the repo is globally known but not selected', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [{ id: 'repo-2' }], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog: null, repoNotInOrca: null }) + }) + + it('keeps missing-repo fallback dialogs when there are no global matches', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog, repoNotInOrca }) + }) }) diff --git a/src/renderer/src/components/github-project/project-dialog-state.ts b/src/renderer/src/components/github-project/project-dialog-state.ts index cb092bcae10..88f71c9fcb1 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.ts @@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = { repo: string } -type LookupSlug = (slug: string) => readonly unknown[] +type RepoMatch = { + id: string +} -function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean { - return lookupSlug(`${owner}/${repo}`).length > 0 +type LookupSlug = (slug: string) => readonly RepoMatch[] + +function shouldCloseFallbackDialog(args: { + lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet + owner: string + repo: string +}): boolean { + const matches = args.lookupSlug(`${args.owner}/${args.repo}`) + const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length + const unselectedMatchCount = matches.length - selectedMatchCount + return selectedMatchCount > 0 || unselectedMatchCount > 0 } export function resolveRepoBackedProjectDialogState( dialog: T | null, - liveRepoIds: ReadonlySet + liveRepoIds: ReadonlySet, + selectedRepoIds: ReadonlySet ): T | null { - if (dialog && !liveRepoIds.has(dialog.repoId)) { + if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) { return null } return dialog @@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState< slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet }): { slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null } { - const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args + const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args if (!slugIndexReady) { - return { slugDialog, repoNotInOrca } + return { slugDialog: null, repoNotInOrca: null } } return { slugDialog: - slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo) + slugDialog && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: slugDialog.origin.owner, + repo: slugDialog.origin.repo + }) ? null : slugDialog, repoNotInOrca: - repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo) + repoNotInOrca && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: repoNotInOrca.owner, + repo: repoNotInOrca.repo + }) ? null : repoNotInOrca } diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index fb061c262b9..f70b44be4a6 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' import type { Repo } from '../../../../shared/types' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsByOpenRepos, + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' function repo(id: string): Repo { return { @@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => { expect(filtered.totalCount).toBe(0) }) }) + +describe('filterProjectTableRowsBySelectedRepos', () => { + it('keeps a row when at least one slug match is selected', () => { + const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + true, + new Set(['repo-1']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['visible']) + expect(filtered.totalCount).toBe(1) + }) + + it('filters a row when only unselected repos match', () => { + const rows = [row('hidden', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-2')], + true, + new Set(['repo-1']) + ) + + expect(filtered.rows).toEqual([]) + expect(filtered.totalCount).toBe(0) + }) + + it('keeps a row with multiple selected matches for action ambiguity handling', () => { + const rows = [row('ambiguous', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + true, + new Set(['repo-1', 'repo-2']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous']) + }) +}) + +describe('resolveSelectedProjectRowRepo', () => { + it('reports loading without reading stale slug matches', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('loading', 'acme/orca'), + lookupSlug: () => { + throw new Error('should not read stale matches') + }, + slugIndexReady: false, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('loading') + }) + + it('reports invalid slug for rows without a repository', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing-slug', null), + lookupSlug: () => [repo('repo-1')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('invalid_slug') + }) + + it('reports no global match when Orca has no repo for the slug', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing', 'acme/orca'), + lookupSlug: () => [], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('no_global_match') + }) + + it('reports global-only matches when the repo is not selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('unselected', 'acme/orca'), + lookupSlug: () => [repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('unselected_match') + }) + + it('returns the selected match when exactly one matching repo is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('selected', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-2']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } }) + }) + + it('reports ambiguity when multiple matching repos are selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('ambiguous', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1', 'repo-2']) + }) + + expect(resolution.status).toBe('ambiguous_selected_match') + }) +}) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 5e5f90d113c..a0c23c20d66 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types' export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[] +export type SelectedProjectRowResolution = + | { status: 'loading' } + | { status: 'invalid_slug' } + | { status: 'no_global_match' } + | { status: 'unselected_match'; globalMatches: readonly Repo[] } + | { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] } + | { + status: 'ambiguous_selected_match' + selectedMatches: readonly Repo[] + globalMatches: readonly Repo[] + } + +export function resolveSelectedProjectRowRepo(input: { + row: GitHubProjectRow + lookupSlug: ProjectRowSlugLookup + slugIndexReady: boolean + selectedRepoIds: ReadonlySet +}): SelectedProjectRowResolution { + if (!input.slugIndexReady) { + return { status: 'loading' } + } + + const repository = input.row.content.repository + if (!repository) { + return { status: 'invalid_slug' } + } + const [owner, repo] = repository.split('/') + if (!owner || !repo) { + return { status: 'invalid_slug' } + } + + const globalMatches = input.lookupSlug(repository) + if (globalMatches.length === 0) { + return { status: 'no_global_match' } + } + + const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + if (selectedMatches.length === 0) { + return { status: 'unselected_match', globalMatches } + } + if (selectedMatches.length === 1) { + return { status: 'selected_match', repo: selectedMatches[0], globalMatches } + } + return { status: 'ambiguous_selected_match', selectedMatches, globalMatches } +} + export function projectRowHasOpenRepo( row: GitHubProjectRow, lookupSlug: ProjectRowSlugLookup @@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos( } return { ...table, rows, totalCount: rows.length } } + +export function filterProjectTableRowsBySelectedRepos( + table: GitHubProjectTable, + lookupSlug: ProjectRowSlugLookup, + slugIndexReady: boolean, + selectedRepoIds: ReadonlySet +): GitHubProjectTable { + const rows = table.rows.filter((row) => { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + return ( + resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match' + ) + }) + if (rows.length === table.rows.length && table.totalCount === rows.length) { + return table + } + return { ...table, rows, totalCount: rows.length } +} diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts index e2bc44cca24..e5eeabc40fc 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectTable } from '../../../../shared/github-project-types' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, - getVisibleProjectTable + getVisibleProjectTable, + getVisibleProjectTableCacheKey } from './project-visible-table-cache' function table(id: string): GitHubProjectTable { @@ -17,20 +19,22 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable, slugIndexReady: true, filteredTable, previous: null }) - ).toEqual({ cacheKey: 'project:view', table: filteredTable }) + ).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable }) }) it('keeps the previous cache while the slug index is rebuilding', () => { - const previous = { cacheKey: 'project:view', table: table('previous') } + const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') } expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: table('source'), slugIndexReady: false, filteredTable: null, @@ -45,6 +49,7 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: null, + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: null, slugIndexReady: false, filteredTable: null, @@ -54,11 +59,12 @@ describe('project visible table cache', () => { }) it('shows a matching cached table while the slug index is rebuilding', () => { - const cachedTable = { cacheKey: 'project:view', table: table('cached') } + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable @@ -67,15 +73,36 @@ describe('project visible table cache', () => { }) it('does not show stale cached data for a different cache key', () => { - const cachedTable = { cacheKey: 'other:view', table: table('cached') } + const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable }) ).toBeNull() }) + + it('does not show stale cached data for a different repo selection', () => { + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } + + expect( + getVisibleProjectTable({ + currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])), + slugIndexReady: false, + filteredTable: null, + cachedTable + }) + ).toBeNull() + }) + + it('layers selection only onto the renderer visible-table cache key', () => { + const storeCacheKey = 'organization:stablyai:1:view-id:local' + + expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey) + }) }) diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts index 2411012259c..6390e897281 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts @@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = { table: GitHubProjectTable } +export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet): string { + return JSON.stringify([...selectedRepoIds].sort()) +} + +export function getVisibleProjectTableCacheKey( + currentCacheKey: string | null, + selectedRepoFingerprint: string +): string | null { + return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null +} + export function getNextVisibleProjectTableCache(input: { currentCacheKey: string | null + selectedRepoFingerprint: string sourceTable: GitHubProjectTable | null slugIndexReady: boolean filteredTable: GitHubProjectTable | null previous: CachedVisibleProjectTable | null }): CachedVisibleProjectTable | null { - if (!input.currentCacheKey || !input.sourceTable) { + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + if (!visibleCacheKey || !input.sourceTable) { return null } if (input.slugIndexReady && input.filteredTable) { - return { cacheKey: input.currentCacheKey, table: input.filteredTable } + return { cacheKey: visibleCacheKey, table: input.filteredTable } } return input.previous } export function getVisibleProjectTable(input: { currentCacheKey: string | null + selectedRepoFingerprint: string slugIndexReady: boolean filteredTable: GitHubProjectTable | null cachedTable: CachedVisibleProjectTable | null @@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: { if (input.slugIndexReady || !input.currentCacheKey) { return input.filteredTable } - return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null } diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index a694b99c44c..1b1d98786a7 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => { ) const modalSection = sourceBetween( TASK_PAGE_SOURCE, - '', + '', ' Date: Tue, 23 Jun 2026 10:34:32 -0700 Subject: [PATCH 48/65] Remove textContentType none from iOS terminal input (#6176) - iOS `textContentType` overrides `autoComplete` and restricts keyboard layouts, preventing switching to non-Latin input methods (IMEs). - Use `autoComplete="off"` instead to ensure the keyboard remains default and IME switching stays available. - Update tests to assert these changes. --- mobile/app/h/[hostId]/session/[worktreeId].tsx | 7 ++++++- mobile/src/terminal/terminal-ios-ime-keyboard.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7be0e3894ea..542dbf53a47 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4915,12 +4915,14 @@ export default function SessionScreen() { autoCorrect={false} spellCheck={false} smartInsertDelete={false} + // Why: iOS textContentType wins over autoComplete and can + // narrow the keyboard surface; keep IME switching available. + autoComplete="off" keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)} returnKeyType="default" blurOnSubmit={false} editable={canSend} importantForAutofill="no" - textContentType="none" /> ) : ( @@ -4946,6 +4948,9 @@ export default function SessionScreen() { autoCorrect={autocompleteEnabled} spellCheck={autocompleteEnabled} smartInsertDelete={false} + // Why: terminal commands are not autofill content, but the + // keyboard must stay default so non-Latin IMEs remain selectable. + autoComplete="off" keyboardType={getTerminalCommandKeyboardType( Platform.OS, autocompleteEnabled diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index 412e9db664a..ac6ee0ff91f 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => { expect(sessionRouteSource).not.toContain("'ascii-capable'") expect(sessionRouteSource).not.toContain('"ascii-capable"') }) + + it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { + expect(sessionRouteSource).not.toContain('textContentType="none"') + expect(sessionRouteSource).toContain('autoComplete="off"') + }) }) From 953b325180385144731170c91271d15ad1e2602e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:38:37 -0700 Subject: [PATCH 49/65] Scope Claude Keychain lookup and disable PTY fallback for system auth (#6180) * Scope Claude Keychain lookup and disable PTY fallback for system auth * Scope macOS Keychain credential lookups to the config directory first to support Claude Code 2.1+, falling back to the legacy system default. * Disable Claude CLI PTY execution fallback for system-default accounts during usage refreshes to avoid launching unexpected browser auth flows. * Disable Claude PTY fallback when auth preparation is unavailable Treat missing Claude auth preparation as system-default. This aligns the PTY fallback gate to prevent background refreshes from triggering interactive authentication flows when no auth is configured. --- src/main/rate-limits/claude-fetcher.test.ts | 73 +++++++++++++++------ src/main/rate-limits/claude-fetcher.ts | 10 ++- src/main/rate-limits/service.test.ts | 63 +++++++++++++++++- src/main/rate-limits/service.ts | 28 ++++++-- 4 files changed, 144 insertions(+), 30 deletions(-) diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index b3c5dc54c20..5c984c607e8 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => { + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => { stripAuthEnv: false, provenance: 'system' } - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', @@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => { weekly: { usedPercent: 34 } }) - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ @@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'scoped-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer scoped-oauth-token' + }) + }) + ) + }) + it('falls back to the credentials file when Keychain access fails', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 2c8db5194cf..94f725c2366 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions( if (!authPreparation) { return undefined } + // Why: Claude Code 2.1+ can scope even the default config dir's macOS + // Keychain item. Try scoped first, with legacy still handled as fallback. const readOptions: OAuthCredentialReadOptions = { - credentialsFileConfigDir: authPreparation.configDir - } - // Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so - // their Keychain lookup must mirror Claude's legacy service ordering. - if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) { - readOptions.keychainConfigDir = authPreparation.configDir + credentialsFileConfigDir: authPreparation.configDir, + keychainConfigDir: authPreparation.configDir } return readOptions } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dc48755b1e5..76bcc8672d6 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -353,7 +353,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: true + allowPtyFallback: false }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) @@ -464,6 +464,67 @@ describe('RateLimitService', () => { expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('does not use Claude PTY fallback for system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { + const service = new RateLimitService() + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: undefined, + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false + }) + }) + it('does not cache host Codex usage under an outgoing WSL account', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 6b6e3c064db..822dfa37101 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number { return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) } +function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles classify missing Claude auth as system-default; keep the + // PTY fallback gate aligned so background refresh cannot trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -792,10 +804,18 @@ export class RateLimitService { return process.platform !== 'win32' } - private shouldAllowClaudePtyFallback(): boolean { + private shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { // Why: automatic recovery uses Claude CLI as the next source, but Windows // hidden PTY support remains less reliable than host/WSL shells. - return process.platform !== 'win32' + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude is not an Orca-managed account. Background + // quota refresh may read existing OAuth, but must not launch Claude and + // trigger auth/browser flows for users who never configured Claude in Orca. + return !isSystemDefaultClaudeAuth(authPreparation) } private withFetchingStatus( @@ -861,7 +881,7 @@ export class RateLimitService { await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }), missingWslCodexHome ?? fetchCodexRateLimits({ @@ -1036,7 +1056,7 @@ export class RateLimitService { const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }).catch( (err): ProviderRateLimits => ({ provider: 'claude', From 0e6ecc3fc19f45b64c171f6b63eea450790c98dd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:43:44 -0700 Subject: [PATCH 50/65] Prevent restored inactive terminal tabs from being flagged as orphans (#6179) Consolidate session binding resolution into a dedicated helper that scans active PTY states, restored tab PTY IDs, and split-pane layout wake hints. This ensures that deferred-reattach terminal sessions are not falsely classified as orphaned before their UI panes mount. --- .../status-bar/ResourceUsageStatusSegment.tsx | 51 +++++---- .../mergeSnapshotAndSessions.test.ts | 21 ++++ .../status-bar/mergeSnapshotAndSessions.ts | 41 ++----- .../resource-session-bindings.test.ts | 101 ++++++++++++++++++ .../status-bar/resource-session-bindings.ts | 86 +++++++++++++++ .../status-bar/resource-usage-merge-types.ts | 4 +- 6 files changed, 241 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/status-bar/resource-session-bindings.test.ts create mode 100644 src/renderer/src/components/status-bar/resource-session-bindings.ts diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index f9f9e72afb5..67095fee7c8 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -68,6 +68,11 @@ import { getResourceManagerAriaLabel, getResourceManagerTooltipLines } from './resource-manager-terminal-copy' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions, + type ResourceSessionBindingInputs +} from './resource-session-bindings' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 @@ -732,6 +737,8 @@ export function ResourceUsageStatusSegment({ const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -778,6 +785,17 @@ export function ResourceUsageStatusSegment({ // While a runtime server is active, hiding local samples avoids showing or // killing sessions from the wrong machine. const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource + // Manager also reads restored wake hints, but only for classification. + const resourceSessionBindings = useMemo( + () => ({ + ptyIdsByTabId, + tabsByWorktree: tabsByWorktreeForPtyBindings, + terminalLayoutsByTabId, + workspaceSessionReady + }), + [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + ) // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -947,6 +965,7 @@ export function ResourceUsageStatusSegment({ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -960,6 +979,7 @@ export function ResourceUsageStatusSegment({ sessions, tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -969,28 +989,12 @@ export function ResourceUsageStatusSegment({ // Why: orphanCount drives the trigger badge (always visible in the status // bar, popover open or not) so it must compute outside the open-gate. - // Build the bound set with a single flat walk instead of nested Object - // iterations to keep this light on every store update. const orphanCount = useMemo(() => { if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } - let n = 0 - for (const s of sessions) { - if (!bound.has(s.id)) { - n++ - } - } - return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) + return countUnboundDaemonSessions(sessions, resourceSessionBindings) + }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1123,14 +1127,7 @@ export function ResourceUsageStatusSegment({ if (!workspaceSessionReady) { return } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } + const bound = buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds const orphans = sessions.filter((s) => !bound.has(s.id)) if (orphans.length === 0) { return @@ -1141,7 +1138,7 @@ export function ResourceUsageStatusSegment({ setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) void refreshSessions() - }, [sessions, ptyIdsByTabId, workspaceSessionReady, refreshSessions]) + }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions]) const runKillConfirmed = useCallback(async () => { if (!killConfirm) { diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 06ed8b088a4..ad0d13dd0da 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -186,6 +186,27 @@ describe('mergeSnapshotAndSessions', () => { expect(out[0].worktrees[0].sessions[0].bound).toBe(true) }) + it('treats startup deferred reattach tab ptyId wake hints as bound sessions', () => { + const tabId = 'tab-restored' + const sessionId = 'orca::/Users/me/Triton@@deferred' + const ds: DaemonSession[] = [{ id: sessionId, cwd: '/Users/me/Triton', title: 'orca/Triton' }] + const restoredTab = { ...makeTab(tabId, 'Restored'), ptyId: sessionId } + const ctx = baseCtx({ + tabsByWorktree: { + 'orca::/Users/me/Triton': [restoredTab] + }, + ptyIdsByTabId: { [tabId]: [] } + }) + + const out = mergeSnapshotAndSessions(null, ds, ctx) + + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId, + bound: true, + tabId + }) + }) + it('repo aggregate sums only worktrees with numeric metrics; remote-by-connectionId flags chip', () => { // Why: a single repo can be both reflected as a snapshot worktree // (covered by the local collector) and a daemon-only session diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 82aa54835fa..6fcfa3b2acf 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -31,6 +31,7 @@ import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' +import { buildResourceSessionBindingIndex } from './resource-session-bindings' // ─── Helpers ──────────────────────────────────────────────────────── @@ -124,34 +125,6 @@ function resolveDaemonSessionLabel( return 'unknown' } -// Why: the previous implementation did O(N) linear scans over -// ptyIdsByTabId / tabsByWorktree for *every* session it processed. With a -// large workspace that's S * (T + W) work per merge — and the merge runs on -// every snapshot poll plus every store mutation. Pre-build O(1) lookup -// indices once per merge instead. -type MergeIndex = { - ptyIdToTabId: Map - tabIdToWorktreeId: Map -} - -function buildMergeIndex(ctx: MergeContext): MergeIndex { - const ptyIdToTabId = new Map() - for (const [tabId, ptyIds] of Object.entries(ctx.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - if (ptyId) { - ptyIdToTabId.set(ptyId, tabId) - } - } - } - const tabIdToWorktreeId = new Map() - for (const [worktreeId, tabs] of Object.entries(ctx.tabsByWorktree)) { - for (const tab of tabs) { - tabIdToWorktreeId.set(tab.id, worktreeId) - } - } - return { ptyIdToTabId, tabIdToWorktreeId } -} - // ─── Public merge function ───────────────────────────────────────── export const UNATTRIBUTED_REPO_ID = '__unattributed__' @@ -164,13 +137,11 @@ export function mergeSnapshotAndSessions( ): UnifiedProjectGroup[] { const repos = new Map() const seenSessionIds = new Set() - const index = buildMergeIndex(ctx) - // Why: bound = the daemon session id appears as a pty id under some tab. - // ptyIdToTabId already encodes that membership in O(1), so the bound set - // is just its keys. - const boundPtyIds = ctx.workspaceSessionReady - ? new Set(index.ptyIdToTabId.keys()) - : new Set() + // Why: pre-build O(1) lookup indices once per merge. This includes live + // ptyIdsByTabId plus deferred-reattach wake hints, so restored inactive + // sessions do not appear as Resource Manager orphans before their pane mounts. + const index = buildResourceSessionBindingIndex(ctx) + const boundPtyIds = index.boundPtyIds function isRepoRemote(repoId: string): boolean { // Why: missing entry === we don't know about this repo (typically the diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts new file mode 100644 index 00000000000..58b13ca0595 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions +} from './resource-session-bindings' + +function makeTab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/workspace', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + type: 'terminal', + paneCount: 1 + } as unknown as TerminalTab +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +describe('resource session bindings', () => { + it('combines live PTYs with restored tab and split-pane wake hints', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: { + 'tab-active': ['pty-live', 'pty-live'] + }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-active'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: { + 'tab-restored': makeLayout({ + 'leaf-1': 'pty-leaf-a', + 'leaf-2': 'pty-leaf-b' + }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId).toEqual( + new Map([ + ['pty-live', 'tab-active'], + ['pty-restored', 'tab-restored'], + ['pty-leaf-a', 'tab-restored'], + ['pty-leaf-b', 'tab-restored'] + ]) + ) + expect([...index.boundPtyIds].sort()).toEqual([ + 'pty-leaf-a', + 'pty-leaf-b', + 'pty-live', + 'pty-restored' + ]) + }) + + it('ignores stale layout-only PTYs after their tab is gone', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: {}, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live', null)] + }, + terminalLayoutsByTabId: { + 'tab-closed': makeLayout({ 'leaf-1': 'pty-closed' }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId.has('pty-closed')).toBe(false) + expect(index.boundPtyIds.has('pty-closed')).toBe(false) + }) + + it('counts only daemon sessions without live or restorable tab bindings', () => { + const count = countUnboundDaemonSessions( + [ + { id: 'pty-live', cwd: '/workspace', title: 'live' }, + { id: 'pty-restored', cwd: '/workspace', title: 'restored' }, + { id: 'pty-orphan', cwd: '/tmp', title: 'orphan' } + ], + { + ptyIdsByTabId: { 'tab-live': ['pty-live'] }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + ) + + expect(count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.ts b/src/renderer/src/components/status-bar/resource-session-bindings.ts new file mode 100644 index 00000000000..f90914f9669 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.ts @@ -0,0 +1,86 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { DaemonSession } from './resource-usage-merge-types' + +export type ResourceSessionBindingInputs = { + tabsByWorktree: Record + ptyIdsByTabId: Record + terminalLayoutsByTabId?: Record + workspaceSessionReady: boolean +} + +export type ResourceSessionBindingIndex = { + ptyIdToTabId: Map + tabIdToWorktreeId: Map + boundPtyIds: Set +} + +function addBinding( + ptyIdToTabId: Map, + tabId: string, + ptyId: string | null | undefined +): void { + if (!ptyId || ptyIdToTabId.has(ptyId)) { + return + } + ptyIdToTabId.set(ptyId, tabId) +} + +export function buildResourceSessionBindingIndex( + inputs: ResourceSessionBindingInputs +): ResourceSessionBindingIndex { + const ptyIdToTabId = new Map() + const tabIdToWorktreeId = new Map() + + for (const [worktreeId, tabs] of Object.entries(inputs.tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + + for (const [tabId, ptyIds] of Object.entries(inputs.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + // Why: startup-deferred reattach intentionally leaves inactive tabs out of + // ptyIdsByTabId, but their daemon sessions are still owned by tab/layout + // wake hints. Resource Manager should not classify those as orphans. + for (const tabs of Object.values(inputs.tabsByWorktree)) { + for (const tab of tabs) { + addBinding(ptyIdToTabId, tab.id, tab.ptyId) + } + } + + for (const [tabId, layout] of Object.entries(inputs.terminalLayoutsByTabId ?? {})) { + if (!tabIdToWorktreeId.has(tabId)) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + return { + ptyIdToTabId, + tabIdToWorktreeId, + boundPtyIds: inputs.workspaceSessionReady ? new Set(ptyIdToTabId.keys()) : new Set() + } +} + +export function countUnboundDaemonSessions( + sessions: readonly DaemonSession[], + inputs: ResourceSessionBindingInputs +): number { + if (!inputs.workspaceSessionReady) { + return 0 + } + const { boundPtyIds } = buildResourceSessionBindingIndex(inputs) + let count = 0 + for (const session of sessions) { + if (!boundPtyIds.has(session.id)) { + count += 1 + } + } + return count +} diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5cf962fcd10..88791b5d36e 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -50,6 +50,8 @@ export type MergeContext = { tabsByWorktree: Record /** From useAppStore: maps tabId -> ptyIds[] for the bound check. */ ptyIdsByTabId: Record + /** From useAppStore: persisted per-leaf PTY wake hints for deferred reattach. */ + terminalLayoutsByTabId?: Record /** From useAppStore: per-tab live pane titles (for label resolution). */ runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ From 0c8c248199e4616435ba0631526ccd3a8786f62b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:58:38 -0700 Subject: [PATCH 51/65] fix: address review findings (#6182) --- .../editor/CombinedDiffFileTree.test.ts | 15 ++++ .../editor/CombinedDiffFileTree.tsx | 75 +++++++++++------ .../components/editor/CombinedDiffViewer.tsx | 64 ++++++++++----- .../src/components/editor/EditorContent.tsx | 3 +- .../editor/combined-diff-entries.test.ts | 5 +- .../editor/combined-diff-entries.ts | 5 +- .../editor/combined-diff-file-tree-model.ts | 6 +- .../components/editor/editor-header.test.ts | 2 +- .../src/components/editor/editor-header.ts | 4 +- .../src/components/editor/editor-labels.ts | 5 +- .../editor/editor-panel-diff-reload.ts | 1 + .../editor/editor-panel-render-model.ts | 4 +- .../editor/markdown-preview-controls.ts | 1 + src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/store/slices/editor.test.ts | 81 ++++++++++++++++++- src/renderer/src/store/slices/editor.ts | 27 +++++-- 16 files changed, 236 insertions(+), 64 deletions(-) diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts index c744c933a1e..493a33af70e 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts @@ -41,10 +41,25 @@ describe('CombinedDiffFileTree navigation mapping', () => { it('maps branch and commit entries to combined section prefixes', () => { const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' } + expect(getCombinedDiffFileTreeSectionKey('all', entry)).toBe('combined-branch:src/view.ts') expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts') expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts') }) + it('keeps all-changes local and branch entries separate', () => { + const localEntry: GitStatusEntry = { + path: 'src/view.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' } + + expect(getCombinedDiffFileTreeSectionKey('all', localEntry)).toBe('unstaged:src/view.ts') + expect(getCombinedDiffFileTreeSectionKey('all', branchEntry)).toBe( + 'combined-branch:src/view.ts' + ) + }) + it('expands a collapsed target section and scrolls to its index', () => { const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' } const toggleSection = vi.fn() diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index 5a92bdae79e..ad461641e8c 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -61,14 +61,14 @@ function buildUncommittedRows( } function buildBranchRows( - mode: Extract, + mode: Extract, entries: readonly CombinedDiffFileTreeEntry[], collapsedDirectoryKeys: ReadonlySet ): CombinedDiffTreeNode[] { const branchEntries = entries.filter( (entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry) ) - const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit' + const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch' const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries)) return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] } @@ -149,12 +149,14 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => - mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [], + mode === 'all' || mode === 'uncommitted' + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) + : [], [collapsedDirectoryKeys, filteredEntries, mode] ) const branchRows = React.useMemo( () => - mode === 'branch' || mode === 'commit' + mode === 'all' || mode === 'branch' || mode === 'commit' ? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys) : [], [collapsedDirectoryKeys, filteredEntries, mode] @@ -280,27 +282,52 @@ export function CombinedDiffFileTree({ 'No files match the current filters.' )}
- ) : mode === 'uncommitted' ? ( - uncommittedGroups.map((group) => ( -
-
- {group.label} + ) : mode === 'all' || mode === 'uncommitted' ? ( + <> + {uncommittedGroups.map((group) => ( +
+
+ {group.label} +
+ {group.rows.map((node) => ( + + ))}
- {group.rows.map((node) => ( - - ))} -
- )) + ))} + {mode === 'all' && branchRows.length > 0 ? ( +
+
+ {translate( + 'auto.components.editor.CombinedDiffFileTree.39b6b9e4e4', + 'Committed on Branch' + )} +
+ {branchRows.map((node) => ( + + ))} +
+ ) : null} + ) : ( branchRows.map((node) => ( (() => { return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) }, [file.branchEntriesSnapshot, liveBranchEntries]) + const renderableBranchEntries = React.useMemo( + () => (branchCompare ? branchEntries : []), + [branchCompare, branchEntries] + ) const commitEntries = React.useMemo( () => file.commitEntriesSnapshot ?? [], [file.commitEntriesSnapshot] ) - const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries - const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted' + const allEntries = React.useMemo( + () => [...uncommittedEntries, ...renderableBranchEntries], + [renderableBranchEntries, uncommittedEntries] + ) + const entries = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries + const treeMode = isAllMode + ? 'all' + : isBranchMode + ? 'branch' + : isCommitMode + ? 'commit' + : 'uncommitted' const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({ mode: treeMode, @@ -477,7 +498,7 @@ export default function CombinedDiffViewer({ pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null setSections( entries.map((entry) => ({ - key: `${'area' in entry ? entry.area : (file.diffSource ?? 'compare')}:${entry.path}`, + key: getCombinedDiffFileTreeSectionKey(treeMode, entry), path: entry.path, status: entry.status, area: 'area' in entry ? entry.area : undefined, @@ -500,7 +521,7 @@ export default function CombinedDiffViewer({ loadSchedulerRef.current.reset() generationRef.current += 1 setGeneration((prev) => prev + 1) - }, [entries, entrySignature, file.diffSource, gitStatusEntries, viewStateKey]) + }, [entries, entrySignature, file.diffSource, gitStatusEntries, treeMode, viewStateKey]) const loadSectionNow = useCallback( async (index: number) => { @@ -510,11 +531,13 @@ export default function CombinedDiffViewer({ loadingIndicesRef.current.add(index) const gen = generationRef.current - const entries = isBranchMode - ? branchEntries - : isCommitMode - ? commitEntries - : uncommittedEntries + const entries = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries const entry = entries[index] if (!entry) { loadingIndicesRef.current.delete(index) @@ -527,7 +550,7 @@ export default function CombinedDiffViewer({ const connectionId = getConnectionId(file.worktreeId) ?? undefined const state = useAppStore.getState() const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if (isBranchMode && branchCompare) { + if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) { result = await withDiffSectionLoadTimeout( getRuntimeGitBranchDiff( { @@ -629,14 +652,16 @@ export default function CombinedDiffViewer({ branchCompare?.baseOid, branchCompare?.headOid, branchCompare?.mergeBase, - branchEntries, + allEntries, commitCompare?.commitOid, commitCompare?.parentOid, commitEntries, file.filePath, file.runtimeEnvironmentId, + isAllMode, isBranchMode, isCommitMode, + renderableBranchEntries, uncommittedEntries ] ) @@ -855,7 +880,7 @@ export default function CombinedDiffViewer({ }, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus]) useEffect(() => { - if (treeMode !== 'uncommitted') { + if (treeMode !== 'all' && treeMode !== 'uncommitted') { return } const handler = (event: Event): void => { @@ -927,7 +952,9 @@ export default function CombinedDiffViewer({ removed: section.removed } - if (isBranchMode && branchCompare) { + const isBranchEntry = section.area === undefined + + if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) { openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) return } @@ -952,6 +979,7 @@ export default function CombinedDiffViewer({ file.filePath, file.runtimeEnvironmentId, file.worktreeId, + isAllMode, isBranchMode, isCommitMode, openBranchDiff, @@ -1145,14 +1173,14 @@ export default function CombinedDiffViewer({ return } - if (file.combinedAlternate.source === 'combined-uncommitted') { + if (file.combinedAlternate.source === 'combined-all') { openAllDiffs(file.worktreeId, file.filePath) return } if (branchSummary && branchSummary.status === 'ready') { openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, { - source: 'combined-uncommitted' + source: 'combined-all' }) } }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) @@ -1447,7 +1475,7 @@ export default function CombinedDiffViewer({ {sections.length}{' '} {translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')} - {isBranchMode && branchCompare + {(isAllMode || isBranchMode) && branchCompare ? translate( 'auto.components.editor.CombinedDiffViewer.6094135eec', ' vs {{value0}}', @@ -1524,7 +1552,7 @@ export default function CombinedDiffViewer({ ) : translate( 'auto.components.editor.CombinedDiffViewer.982d14bfa5', - 'Open Uncommitted Diff' + 'Open All Changes' )} )} @@ -1601,7 +1629,7 @@ export default function CombinedDiffViewer({ toggleSection={toggleSection} openSection={openSection} openSectionTitle={ - isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' + isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' } setSectionHeights={setSectionHeights} setSections={setSections} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index b45d224e334..ed813e0ecf3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -199,7 +199,8 @@ export function EditorContent({ const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index 2aa4935564c..859cd92d882 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -35,7 +35,7 @@ describe('getCombinedUncommittedEntries', () => { ]) }) - it('excludes untracked entries when no area filter is set', () => { + it('includes every area when no area filter is set', () => { const liveEntries: GitStatusEntry[] = [ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, @@ -44,7 +44,8 @@ describe('getCombinedUncommittedEntries', () => { expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' } + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, + { path: 'src/untracked.ts', status: 'untracked', area: 'untracked' } ]) }) }) diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index c2ca3526d8c..e27e5037644 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -15,10 +15,7 @@ export function getCombinedUncommittedEntries( if (entry.conflictStatus === 'unresolved') { return false } - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) } diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts index 5f92d27f6d9..cbffbbc51e3 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -2,7 +2,7 @@ import { basename } from '@/lib/path' import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit' +export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' @@ -20,10 +20,10 @@ export function getCombinedDiffFileTreeSectionKey( mode: CombinedDiffFileTreeMode, entry: CombinedDiffFileTreeEntry ): string { - if (mode === 'uncommitted' && 'area' in entry) { + if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { return `${entry.area}:${entry.path}` } - return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}` + return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` } export function createCombinedDiffSectionIndexMap( diff --git a/src/renderer/src/components/editor/editor-header.test.ts b/src/renderer/src/components/editor/editor-header.test.ts index 7cb9e26deec..0939bc8dae0 100644 --- a/src/renderer/src/components/editor/editor-header.test.ts +++ b/src/renderer/src/components/editor/editor-header.test.ts @@ -98,7 +98,7 @@ describe('getEditorHeaderCopyState', () => { filePath: '/repo/worktree', relativePath: 'All Changes', mode: 'diff', - diffSource: 'combined-uncommitted' + diffSource: 'combined-all' }) ) ).toEqual({ diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index 9cfc39fe495..a3fbafbd6da 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -35,7 +35,8 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState const isCombinedDiff = file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || + (file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch' || file.diffSource === 'combined-commit') @@ -66,6 +67,7 @@ export function getEditorHeaderOpenFileState( const isSingleDiff = file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 50e55931db3..c39f38abe36 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -42,9 +42,12 @@ export function getEditorDisplayLabel( } const source = file.diffSource - if (source === 'combined-uncommitted') { + if (source === 'combined-all') { return 'All Changes' } + if (source === 'combined-uncommitted') { + return file.combinedAreaFilter ? getBaseLabel(file, variant) : 'Uncommitted Changes' + } if (source === 'combined-branch') { return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})` } diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.ts index ed33a5cbf31..4b58a471239 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.ts @@ -5,6 +5,7 @@ export function isReloadableSingleFileDiffTab(file: OpenFile): boolean { return ( file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' 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 85463f36d64..be9738db6a4 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -40,12 +40,14 @@ export function getEditorPanelRenderModel({ const isSingleDiff = activeFile.mode === 'diff' && activeFile.diffSource !== undefined && + activeFile.diffSource !== 'combined-all' && activeFile.diffSource !== 'combined-uncommitted' && activeFile.diffSource !== 'combined-branch' && activeFile.diffSource !== 'combined-commit' const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') const resolvedLanguage = diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index df0762ff483..fc6ef9f5c1b 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -44,6 +44,7 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma } if ( target.mode === 'diff' && + target.diffSource !== 'combined-all' && target.diffSource !== 'combined-uncommitted' && target.diffSource !== 'combined-branch' && target.diffSource !== 'combined-commit' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ae48b785015..11712baf910 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10426,7 +10426,7 @@ "f786fd54e1": "Inline", "ea08dae15b": "Collapse All", "19c45cfdc0": "Expand All", - "982d14bfa5": "Open Uncommitted Diff", + "982d14bfa5": "Open All Changes", "3d909843bb": "Open Branch Diff", "8368d256ec": "combined-branch", "8f68ad9ca9": "Show {{value0}} AI {{value1}}", diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 6423b997134..315f4740310 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -11,7 +11,12 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { GitStatusEntry, Tab } from '../../../../shared/types' +import type { + GitBranchChangeEntry, + GitBranchCompareSummary, + GitStatusEntry, + Tab +} from '../../../../shared/types' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -2648,6 +2653,80 @@ describe('createEditorSlice combined diff exclusions', () => { }) ) }) + + it('includes untracked files in the all changes snapshot', () => { + const store = createEditorStore() + const stagedEntry: GitStatusEntry = { + path: 'src/staged.ts', + status: 'modified', + area: 'staged' + } + const untrackedEntry: GitStatusEntry = { + path: 'src/new.ts', + status: 'untracked', + area: 'untracked' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [stagedEntry, untrackedEntry] + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + uncommittedEntriesSnapshot: [stagedEntry, untrackedEntry] + }) + ) + }) + + it('opens all changes with uncommitted and committed branch snapshots', () => { + const store = createEditorStore() + const localEntry: GitStatusEntry = { + path: 'src/local.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { + path: 'src/committed.ts', + status: 'modified' + } + const branchSummary: GitBranchCompareSummary = { + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'HEAD', + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + changedFiles: 1, + status: 'ready' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [localEntry] + }) + store.setState({ + gitBranchCompareSummaryByWorktree: { 'wt-1': branchSummary }, + gitBranchChangesByWorktree: { 'wt-1': [branchEntry] } + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + diffSource: 'combined-all', + uncommittedEntriesSnapshot: [localEntry], + branchEntriesSnapshot: [branchEntry], + branchCompare: expect.objectContaining({ + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }) + }) + ) + }) }) describe('createEditorSlice remote branch actions', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fa2d9ea1d63..3780be8c6e3 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -94,6 +94,7 @@ export type DiffSource = | 'staged' | 'branch' | 'commit' + | 'combined-all' | 'combined-uncommitted' | 'combined-branch' | 'combined-commit' @@ -155,7 +156,7 @@ type CommitCompareLike = Pick< } type CombinedDiffAlternate = { - source: 'combined-uncommitted' | 'combined-branch' + source: 'combined-all' | 'combined-branch' branchCompare?: BranchCompareSnapshot } @@ -2657,13 +2658,22 @@ export const createEditorSlice: StateCreator = (s ] ?? 'All Changes') : 'All Changes' set((s) => { + const branchSummary = s.gitBranchCompareSummaryByWorktree[worktreeId] + const branchCompare = + !areaFilter && + branchSummary?.status === 'ready' && + branchSummary.baseOid && + branchSummary.headOid && + branchSummary.mergeBase + ? toBranchCompareSnapshot(branchSummary) + : undefined + const branchEntriesSnapshot = branchCompare + ? (s.gitBranchChangesByWorktree[worktreeId] ?? []) + : undefined const relevantEntries = entriesSnapshot ?? (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => { - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) const skippedConflicts = relevantEntries .filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind) @@ -2687,6 +2697,9 @@ export const createEditorSlice: StateCreator = (s f.id === id ? { ...f, + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, @@ -2710,7 +2723,9 @@ export const createEditorSlice: StateCreator = (s language: 'plaintext', isDirty: false, mode: 'diff', - diffSource: 'combined-uncommitted', + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, From fac7e0440cbebaab8b962641dfb0b7dc7e42faea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:07:09 -0700 Subject: [PATCH 52/65] Prevent sidebar crashes from missing worktree titles (#6127) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeCard.tsx | 8 ++- .../worktree-card-title-display.test.ts | 72 ++++++++++++++++++- .../sidebar/worktree-card-title-display.ts | 39 ++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index d41cf15e57a..d8f306c0508 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,7 +45,10 @@ import { import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' @@ -505,7 +508,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issueTitle: issueDisplay?.title, reviewTitle: prDisplay?.title }) - const visibleCardTitle = newCardStyle ? cardTitleDisplay : worktree.displayName + const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) + const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false const deleteModifierPressed = useWorkspaceDeleteModifierPressed() diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts index 5e56d7df4ea..bb5cf7625c4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' describe('worktree card title display', () => { it('keeps custom workspace titles', () => { @@ -10,6 +13,14 @@ describe('worktree card title display', () => { reviewTitle: 'Fix stale PR' }) ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' Custom workspace ', + branchName: 'feature/custom', + reviewTitle: 'Fix stale PR' + }) + ).toBe(' Custom workspace ') }) it('uses linked work titles instead of repeating the branch as the card title', () => { @@ -49,4 +60,63 @@ describe('worktree card title display', () => { }) ).toBe('test454545') }) + + it('uses linked work titles when the stored title is nullish and the branch is usable', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: undefined, + branchName: 'feature/local-branch', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Fix stale GH PR') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: 'feature/local-branch', + issueTitle: 'Fix stale issue' + }) + ).toBe('Fix stale issue') + }) + + it('treats blank stored titles as absent', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' ', + branchName: 'feature/local-branch' + }) + ).toBe('') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: '', + branchName: 'feature/local-branch', + linearIssueTitle: 'Fix stale Linear issue' + }) + ).toBe('Fix stale Linear issue') + }) + + it('skips linked-title replacement when the branch name is nullish or blank', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: 'Custom workspace', + branchName: undefined, + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: ' ', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('') + }) + + it('coerces legacy visible titles before downstream title operations', () => { + expect(coerceWorktreeCardVisibleTitle(undefined).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(null).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(' Custom workspace ')).toBe(' Custom workspace ') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.ts index 5da84c1f5f4..be0aa9200d7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.ts @@ -1,11 +1,16 @@ type WorktreeCardTitleDisplayInput = { - storedDisplayName: string - branchName: string + storedDisplayName: string | null | undefined + branchName: string | null | undefined linearIssueTitle?: string | null issueTitle?: string | null reviewTitle?: string | null } +function normalizeComparableTitle(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + function normalizeTitle(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { @@ -17,8 +22,17 @@ function normalizeTitle(value: string | null | undefined): string | null { return trimmed } -function isBranchTitle(displayName: string, branchName: string): boolean { - return displayName.trim() === branchName.trim() +function isBranchTitle( + normalizedDisplayName: string | null, + normalizedBranchName: string | null +): boolean { + return normalizedDisplayName !== null && normalizedDisplayName === normalizedBranchName +} + +export function coerceWorktreeCardVisibleTitle(value: string | null | undefined): string { + // Why: the legacy card path can bypass title selection but still feeds trim() + // and inline rename props, so nullish persisted titles stop at this boundary. + return typeof value === 'string' ? value : '' } export function getWorktreeCardTitleDisplay({ @@ -28,8 +42,19 @@ export function getWorktreeCardTitleDisplay({ issueTitle, reviewTitle }: WorktreeCardTitleDisplayInput): string { - if (!branchName || !isBranchTitle(storedDisplayName, branchName)) { - return storedDisplayName + const normalizedStoredDisplayName = normalizeComparableTitle(storedDisplayName) + const normalizedBranchName = normalizeComparableTitle(branchName) + const visibleStoredDisplayName = coerceWorktreeCardVisibleTitle(storedDisplayName) + + if (!normalizedBranchName) { + return normalizedStoredDisplayName ? visibleStoredDisplayName : '' + } + + if ( + normalizedStoredDisplayName && + !isBranchTitle(normalizedStoredDisplayName, normalizedBranchName) + ) { + return visibleStoredDisplayName } // Why: branch names are available in hover/details; the closed card title @@ -38,6 +63,6 @@ export function getWorktreeCardTitleDisplay({ normalizeTitle(linearIssueTitle) ?? normalizeTitle(issueTitle) ?? normalizeTitle(reviewTitle) ?? - storedDisplayName + (normalizedStoredDisplayName ? visibleStoredDisplayName : '') ) } From 8c4f57e8efeca186d7c8bc5f68cbce3157b84634 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:07:49 -0700 Subject: [PATCH 53/65] Reduce repeated git probes during worktree refresh (#6153) Co-authored-by: Orca --- src/main/github/client.test.ts | 102 +++++- src/main/github/client.ts | 73 ++++ src/main/ipc/worktree-change-invalidators.ts | 25 ++ src/main/ipc/worktree-remote.ts | 4 + src/main/ipc/worktrees.test.ts | 317 +++++++++++++++++- src/main/ipc/worktrees.ts | 92 ++++- .../attach-main-window-services.test.ts | 14 +- .../window/attach-main-window-services.ts | 9 +- 8 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/worktree-change-invalidators.ts diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 8493317a4c0..9e6e5eb92bf 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -110,7 +110,8 @@ import { updatePRTitle, _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, - _resetMergeQueueCacheForTests + _resetMergeQueueCacheForTests, + __resetTrackedUpstreamBranchCacheForTests } from './client' describe('checkOrcaStarred', () => { @@ -179,6 +180,7 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetTrackedUpstreamBranchCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => { }) }) + it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('coalesces concurrent missing tracked-upstream probes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockImplementation(async () => { + await Promise.resolve() + throw new Error("fatal: no upstream configured for branch 'no-pr-branch'") + }) + + await Promise.all([ + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch') + ]) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(trackedUpstreamCalls[1][1]).toEqual({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + }) + + it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { + vi.useFakeTimers() + try { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) + .mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root', 'feature') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('feature@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('uses the tracked upstream remote owner for fork branch lookup', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6bf0976bac3..158c28ef4d6 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = { branchName: string } +const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000 + +type TrackedUpstreamNullCacheEntry = { + expiresAt: number +} + +const trackedUpstreamNullCache = new Map() +const trackedUpstreamInFlight = new Map>() + +export function __resetTrackedUpstreamBranchCacheForTests(): void { + trackedUpstreamNullCache.clear() + trackedUpstreamInFlight.clear() +} + function parseTrackedUpstreamBranch( upstreamRef: string, branchName: string @@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch( branchName: string, connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} +): Promise { + // Why: branches without configured upstreams are stable misses during PR + // polling; cache only nulls so positive PR discovery stays fresh. + const cacheKey = getTrackedUpstreamBranchCacheKey( + repoPath, + branchName, + connectionId, + localGitOptions + ) + const now = Date.now() + const cachedNull = trackedUpstreamNullCache.get(cacheKey) + if (cachedNull && cachedNull.expiresAt > now) { + return null + } + if (cachedNull) { + trackedUpstreamNullCache.delete(cacheKey) + } + + const inFlight = trackedUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions) + trackedUpstreamInFlight.set(cacheKey, probe) + try { + const result = await probe + if (result) { + trackedUpstreamNullCache.delete(cacheKey) + } else { + trackedUpstreamNullCache.set(cacheKey, { + expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS + }) + } + return result + } finally { + if (trackedUpstreamInFlight.get(cacheKey) === probe) { + trackedUpstreamInFlight.delete(cacheKey) + } + } +} + +function getTrackedUpstreamBranchCacheKey( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): string { + const runtimeKey = connectionId + ? `ssh:${connectionId}` + : `local:${localGitOptions.wslDistro ?? 'host'}` + return [runtimeKey, repoPath, branchName].join('\0') +} + +async function probeTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] try { diff --git a/src/main/ipc/worktree-change-invalidators.ts b/src/main/ipc/worktree-change-invalidators.ts new file mode 100644 index 00000000000..a43657743b4 --- /dev/null +++ b/src/main/ipc/worktree-change-invalidators.ts @@ -0,0 +1,25 @@ +const worktreeChangeInvalidators = new Set<(repoId: string) => void>() + +export function registerWorktreeChangeInvalidator( + invalidator: (repoId: string) => void +): () => void { + worktreeChangeInvalidators.add(invalidator) + return () => { + worktreeChangeInvalidators.delete(invalidator) + } +} + +export function runWorktreeChangeInvalidators(repoId: string): void { + const invalidators = Array.from(worktreeChangeInvalidators) + for (const invalidator of invalidators) { + try { + invalidator(repoId) + } catch (error) { + console.warn('[worktrees] worktree change invalidator failed:', error) + } + } +} + +export function __resetWorktreeChangeInvalidatorsForTests(): void { + worktreeChangeInvalidators.clear() +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 50fb87e58fb..74dfb7db9f8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' +import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( } export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { + // Why: invalidate detected-worktree caches before renderer observers react, + // so follow-up listDetected reads post-change state. + runWorktreeChangeInvalidators(repoId) if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3b7130d040d..0f043c7c6fe 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' const ORIGINAL_PLATFORM = process.platform @@ -226,9 +226,12 @@ vi.mock('./pty', () => ({ getLocalPtyProvider: getLocalPtyProviderMock })) -import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' +import { + __resetSshWorktreeCreateFetchCacheForTests, + notifyWorktreesChanged +} from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' type HandlerMap = Record unknown> @@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() + __resetDetectedWorktreeScanCacheForTests() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => { }) }) + it('reuses a recent authoritative detected worktree scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + + expect(first).toEqual(second) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('coalesces concurrent authoritative detected worktree scans', async () => { + listWorktreesMock.mockImplementation(async () => { + await Promise.resolve() + return [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + }) + + await Promise.all([ + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ]) + + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rechecks detected worktree metadata while reusing a cached raw scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + let currentMeta = makeWorktreeMeta({ isPinned: false }) + store.getWorktreeMeta.mockImplementation(() => currentMeta) + store.setWorktreeMeta.mockImplementation(() => currentMeta) + const first = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + currentMeta = makeWorktreeMeta({ isPinned: true }) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(first.worktrees[0].isPinned).toBe(false) + expect(second.worktrees[0].isPinned).toBe(true) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rescans detected worktrees after the scan cache TTL expires', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(5_001) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/new-worktree' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('starts the detected scan cache TTL after a slow scan completes', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + 6_000 + ) + }) + ) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(6_000) + await first + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo']) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('invalidates the detected scan cache before worktree change notifications', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees).toHaveLength(2) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('rescans detected worktrees after the local create flow notifies worktree changes', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + const detected = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/improve-dashboard' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(3) + }) + + it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/new-worktree': { + worktreeId: 'repo-1::/workspace/new-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await pendingList + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 564d369d914..f550d11079a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -77,6 +77,7 @@ import { cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' +import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' import { invalidateAuthorizedRootsCache, isENOENT, @@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +// Why: absorb renderer polling bursts while keeping external worktree-change +// lag bounded to one short refresh window. +const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +const detectedWorktreeScanCache = new Map() +const detectedWorktreeScanInFlight = new Map>() +const detectedWorktreeScanGenerations = new Map() + +function invalidateDetectedWorktreeScanCache(repoId: string): void { + detectedWorktreeScanCache.delete(repoId) + detectedWorktreeScanInFlight.delete(repoId) + detectedWorktreeScanGenerations.set( + repoId, + (detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1 + ) +} + +registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) + +export function __resetDetectedWorktreeScanCacheForTests(): void { + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() + detectedWorktreeScanGenerations.clear() +} + +async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)), + fresh: true + } + } + + const cached = detectedWorktreeScanCache.get(repo.id) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(repo.id) + if (inFlight) { + return { gitWorktrees: await inFlight, fresh: false } + } + + const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)) + const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0 + detectedWorktreeScanInFlight.set(repo.id, scan) + try { + const gitWorktrees = await scan + // Why: a create/remove notification can invalidate while the git scan is + // still running. Do not let that stale scan repopulate the cache afterward. + const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation + if (isCurrentGeneration) { + detectedWorktreeScanCache.set(repo.id, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: isCurrentGeneration } + } finally { + if (detectedWorktreeScanInFlight.get(repo.id) === scan) { + detectedWorktreeScanInFlight.delete(repo.id) + } + } +} function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { @@ -946,6 +1024,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees: GitWorktreeInfo[] + let freshScan = true if (isFolderRepo(repo)) { return { repoId: repo.id, @@ -966,13 +1045,14 @@ export function registerWorktreeHandlers( } gitWorktrees = await provider.listWorktrees(repo.path) } else { - gitWorktrees = await listRepoWorktrees( - repo, - getLocalProjectWorktreeGitOptions(store, repo) - ) + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return { repoId: repo.id, diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1091ef71417..f69ca694c4a 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,8 @@ const { registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, - browserManagerUnregisterAllMock + browserManagerUnregisterAllMock, + runWorktreeChangeInvalidatorsMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -33,7 +34,8 @@ const { registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), - browserManagerUnregisterAllMock: vi.fn() + browserManagerUnregisterAllMock: vi.fn(), + runWorktreeChangeInvalidatorsMock: vi.fn() })) vi.mock('electron', () => ({ @@ -64,6 +66,10 @@ vi.mock('../ipc/worktrees', () => ({ registerWorktreeHandlers: registerWorktreeHandlersMock })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ + runWorktreeChangeInvalidators: runWorktreeChangeInvalidatorsMock +})) + vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock @@ -592,5 +598,9 @@ describe('attachMainWindowServices', () => { } ] ]) + expect(runWorktreeChangeInvalidatorsMock).toHaveBeenCalledWith('repo-1') + expect(runWorktreeChangeInvalidatorsMock.mock.invocationCallOrder[0]).toBeLessThan( + sendMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ce720ca612c..eace514bcc9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -36,6 +36,7 @@ import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../share import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -216,8 +217,12 @@ function registerRuntimeWindowLifecycle( } } runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => - send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }), + worktreesChanged: (repoId, renamed) => { + // Why: clear detected-worktree scan caches before renderer listeners + // handle this event, preventing stale TTL reads after mutations. + runWorktreeChangeInvalidators(repoId) + send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) + }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), reposChanged: () => send('repos:changed'), From 03fd869b9232e302291a2a189dae2cddd5647a7a Mon Sep 17 00:00:00 2001 From: Salah Alkhwlani Date: Tue, 23 Jun 2026 21:18:17 +0300 Subject: [PATCH 54/65] feat(ai-vault): support OpenCode SQLite session storage (#5925) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../ai-vault/session-scanner-agent-parser.ts | 25 +- .../session-scanner-codex-workers.test.ts | 1 + .../session-scanner-opencode-sources.ts | 73 +++ ...canner-opencode-sqlite-coexistence.test.ts | 215 ++++++++ ...ssion-scanner-opencode-sqlite-discovery.ts | 242 +++++++++ ...sion-scanner-opencode-sqlite-paths.test.ts | 47 ++ .../session-scanner-opencode-sqlite-paths.ts | 56 ++ .../session-scanner-opencode-sqlite.test.ts | 499 ++++++++++++++++++ .../session-scanner-opencode-sqlite.ts | 263 +++++++++ .../session-scanner-source-discovery.ts | 27 +- src/main/ai-vault/session-scanner-types.ts | 3 + src/main/ai-vault/session-scanner.test.ts | 3 + src/main/ai-vault/session-scanner.ts | 9 + src/main/opencode-usage/scanner.ts | 13 +- src/main/opencode-usage/schema-helpers.ts | 31 ++ 15 files changed, 1473 insertions(+), 34 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sources.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite.ts create mode 100644 src/main/opencode-usage/schema-helpers.ts diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index f65bb8eec6a..385b2961984 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -7,6 +7,8 @@ import { parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' import { parseClaudeSessionFile, parseCodexSessionFile, @@ -20,6 +22,15 @@ import { } from './session-scanner-secondary-parsers' import type { SessionFileCandidate } from './session-scanner-types' +/** + * Parse a single agent session file into an `AiVaultSession`. Routes to the + * appropriate agent-specific parser based on `candidate.agent`. For OpenCode + * SQLite candidates (synthetic `db#id` paths), routes to + * `parseOpenCodeSqliteSession` instead of the legacy JSON parser. + * @param candidate - The session file candidate to parse. + * @param platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if parsing fails. + */ export async function parseAgentSessionFile( candidate: SessionFileCandidate, platform: NodeJS.Platform @@ -35,8 +46,20 @@ export async function parseAgentSessionFile( return parseCopilotSessionFile(candidate.file, platform) case 'cursor': return parseCursorSessionFile(candidate.file, platform) - case 'opencode': + case 'opencode': { + // Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic + // # candidate path. Legacy file-based sessions use + // real filesystem paths and fall through to the JSON parser. + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path) + if (sqliteCandidate) { + return parseOpenCodeSqliteSession({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + } return parseOpenCodeSessionFile(candidate.file, platform) + } case 'grok': return parseGrokSessionFile(candidate.file, platform) case 'hermes': 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 b40b5f591ca..6f99151f059 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts new file mode 100644 index 00000000000..24069c7a0f2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -0,0 +1,73 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const OPENCODE_STORAGE_DIR = join( + process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), + 'storage' +) + +export function opencodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const storageDirs = opencodeStorageDirs(options, wslHomeDirs) + return storageDirs.map(async (storageDir, index) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + limitPerAgent: limit, + issues + }) + ) +} + +function opencodeStorageDirs( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[] +): string[] { + return [ + options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) + ] +} + +async function opencodeDbPathsForSource( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + storageDir: string, + sourceIndex: number +): Promise { + if (options.opencodeDbPaths) { + return sourceIndex === 0 ? options.opencodeDbPaths : [] + } + // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. + if (sourceIndex === 0 && options.opencodeStorageDir) { + return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + } + if (sourceIndex === 0) { + return listOpenCodeDatabases() + } + const wslHomeDir = wslHomeDirs[sourceIndex - 1] + return wslHomeDir + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + : [] +} + +async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { + try { + const entries = await readdir(dataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDir, entry.name)) + .sort() + } catch { + return [] + } +} 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 new file mode 100644 index 00000000000..4ae4ea22284 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -0,0 +1,215 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import Database from '../sqlite/sync-database' + +let tempRoots: string[] = [] +let tempDbDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + for (const dir of tempDbDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempRoots = [] + tempDbDirs = [] +}) + +function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +function createTempOpenCodeDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-')) + tempDbDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER, + model TEXT, + agent TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) +} + +describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => { + it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const opencodeDataDir = join(root, 'custom-opencode') + const opencodeStorageDir = join(opencodeDataDir, 'storage') + await mkdir(opencodeStorageDir, { recursive: true }) + + const dbPath = join(opencodeDataDir, 'opencode.db') + const db = new Database(dbPath) + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode', + 'Custom SQLite session', '1.0.0', + 1777634010000, 1777634011000, NULL, 'build', 0, + 8, 13, 21, 34, 0)` + ).run() + db.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeStorageDir, + opencodeDbPaths: undefined, + platform: 'darwin', + limit: 50 + }) + + const session = result.sessions.find((s) => s.sessionId === 'custom-db-session') + expect(session).toBeDefined() + expect(session!.agent).toBe('opencode') + expect(session!.title).toBe('Custom SQLite session') + expect(session!.filePath).toBe(dbPath) + expect(session!.totalTokens).toBe(42) + }) + + it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + // Legacy file session under storage/session//.json + await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true }) + await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true }) + await writeFile( + join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'), + JSON.stringify({ + id: 'legacy-session', + directory: '/tmp/legacy', + title: 'Legacy file session', + time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 } + }) + ) + await writeFile( + join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'), + JSON.stringify({ + role: 'user', + summary: { title: 'Legacy file session' }, + time: { created: 1_777_634_000_000 }, + tokens: { input: 5, output: 2 } + }) + ) + + // SQLite session — same sessionId as the legacy file (dedup should keep SQLite) + const { db, path: dbPath } = createTempOpenCodeDb() + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0', + 1777634002000, 1777634003000, ?, 'build', 0, + 100, 40, 10, 5, 0)` + ).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' })) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } })) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ type: 'text', text: 'sqlite hello' })) + db.close() + + // A second SQLite-only session + const { db: db2, path: dbPath2 } = createTempOpenCodeDb() + applyOpenCodeSchema(db2) + db2 + .prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0', + 1777634004000, 1777634005000, NULL, 'build', 0, + 50, 20, 0, 0, 0)` + ) + .run() + db2.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeDbPaths: [dbPath, dbPath2], + platform: 'darwin', + limit: 50 + }) + + const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode') + const sessionIds = opencodeSessions.map((s) => s.sessionId).sort() + expect(sessionIds).toEqual(['legacy-session', 'sqlite-only']) + + // Why: dedup keeps the SQLite entry (newer time_updated, source of truth) + const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session') + expect(legacyEntry).toBeDefined() + expect(legacyEntry!.title).toBe('SQLite session') + expect(legacyEntry!.cwd).toBe('/tmp/sqlite') + expect(legacyEntry!.filePath).toBe(dbPath) + expect(legacyEntry!.totalTokens).toBe(150) + expect(legacyEntry!.resumeCommand).toBe( + "cd '/tmp/sqlite' && opencode --session 'legacy-session'" + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts new file mode 100644 index 00000000000..3c465007992 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts @@ -0,0 +1,242 @@ +import { basename, extname, join } from 'path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { + FileWithMtime, + SessionFileCandidate, + SessionFileDiscovery +} from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: keep the SQLite discovery + dedup layer separate from the parser so +// each file stays under the max-lines lint rule and the discovery layer can +// be tested in isolation. + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionListQuery(db: SyncDatabase): string { + const modelSelect = sessionColumnSelect(db, 'model') + const agentSelect = sessionColumnSelect(db, 'agent') + const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'] + const tokenSelects = tokenColumns + .map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`) + .join(', ') + const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0' + const parentIdPredicate = columnExists(db, 'session', 'parent_id') + ? 'AND s.parent_id IS NULL' + : '' + const archivedPredicate = columnExists(db, 'session', 'time_archived') + ? 'AND s.time_archived IS NULL' + : '' + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${modelSelect} AS model_json, ${agentSelect} AS agent, + ${tokenSelects}, ${costSelect} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + ORDER BY s.time_updated DESC + LIMIT ?` +} + +function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + return { + agent: 'opencode' as AiVaultAgent, + file: { + path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + codexHome: null + } +} + +function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { + const candidatesBySessionId = new Map() + for (const candidate of candidates) { + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + if (!parsed) { + continue + } + const previous = candidatesBySessionId.get(parsed.sessionId) + if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) { + candidatesBySessionId.set(parsed.sessionId, candidate) + } + } + return [...candidatesBySessionId.values()].sort((left, right) => { + return right.file.mtimeMs - left.file.mtimeMs + }) +} + +/** + * List OpenCode sessions from one or more SQLite databases as synthetic + * `SessionFileCandidate` entries. Each candidate's file path is a synthetic + * `#` string that the parser dispatcher routes to + * `parseOpenCodeSqliteSession`. Databases that lack the `session` table are + * silently skipped; errors are recorded as scan issues. + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limit - Maximum number of sessions to return per database. + * @param args.issues - Collected scan issues to append errors to. + * @returns Array of synthetic candidates sorted by `time_updated` DESC. + */ +export async function listOpenCodeSqliteSessions(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const candidates: SessionFileCandidate[] = [] + for (const dbPath of args.dbPaths) { + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + continue + } + const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + for (const row of rows) { + candidates.push(rowToCandidate(row, dbPath)) + } + } catch (err) { + args.issues.push({ + agent: 'opencode', + path: dbPath, + message: errorMessage(err) + }) + } finally { + db?.close() + } + } + return dedupeAndSortSqliteCandidates(candidates) +} + +// Why: extract the sessionId from a legacy file path like +// storage/session//.json. Falls back to the filename +// without extension when the opencode id format doesn't match a UUID. +function sessionIdFromLegacyFilePath(filePath: string): string { + return basename(filePath, extname(filePath)) +} + +/** + * Discover OpenCode sessions from both the legacy file layout and the SQLite + * DB, deduplicating at the file level before parsing. On mixed installs the + * same session may appear once via a stale legacy JSON file and once via the + * SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries + * whose sessionId matches a SQLite entry are dropped. Legacy installs without + * the `session` table fall through to the file scanner unchanged. + * @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`). + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limitPerAgent - Maximum number of candidates per source. + * @param args.issues - Collected scan issues to append errors to. + * @returns A `SessionFileDiscovery` with deduplicated file entries. + */ +export async function discoverOpenCodeSessions(args: { + storageDir: string + dbPaths: readonly string[] + limitPerAgent: number + issues: AiVaultScanIssue[] +}): Promise { + const [fileDiscovery, sqliteCandidates] = await Promise.all([ + discoverFiles({ + rootDir: join(args.storageDir, 'session'), + limit: args.limitPerAgent, + agent: 'opencode', + issues: args.issues, + extensions: ['.json'] + }), + listOpenCodeSqliteSessions({ + dbPaths: args.dbPaths, + limit: args.limitPerAgent, + issues: args.issues + }) + ]) + + const sqliteFiles = sqliteCandidates.map((c) => c.file) + // Why: on mixed installs the same OpenCode session may appear once via the + // SQLite DB and once via a stale legacy JSON file. SQLite is the source of + // truth on 1.17.x, so drop file-based duplicates when a SQLite entry with + // the same sessionId already exists. Deduping at the file level also avoids + // parsing the same session twice. + if (sqliteFiles.length === 0) { + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: fileDiscovery.files + } + } + const sqliteSessionIds = new Set() + for (const file of sqliteFiles) { + const parsed = splitOpenCodeSqliteCandidate(file.path) + if (parsed) { + sqliteSessionIds.add(parsed.sessionId) + } + } + const dedupedFileDiscovery: FileWithMtime[] = [] + for (const file of fileDiscovery.files) { + if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) { + dedupedFileDiscovery.push(file) + } + } + + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: [...dedupedFileDiscovery, ...sqliteFiles] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts new file mode 100644 index 00000000000..db9c60f0b70 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildOpenCodeSqliteCandidatePath, + looksLikeOpenCodeSqliteCandidate, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' + +describe('splitOpenCodeSqliteCandidate', () => { + it('splits a synthetic db#sessionId path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc') + expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' }) + }) + + it('splits a stable-db path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz') + expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' }) + }) + + it('rejects a path whose db basename is not opencode*.db', () => { + expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull() + expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull() + }) + + it('rejects a path without a separator', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull() + }) + + it('rejects an empty sessionId', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull() + }) +}) + +describe('looksLikeOpenCodeSqliteCandidate', () => { + it('returns true for a synthetic path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true) + }) + + it('returns false for a real filesystem path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false) + }) +}) + +describe('buildOpenCodeSqliteCandidatePath', () => { + it('joins dbPath and sessionId with #', () => { + expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts new file mode 100644 index 00000000000..fb1415af04e --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -0,0 +1,56 @@ +import { basename } from 'path' + +// Why: keep the synthetic candidate-path helpers separate from the SQLite +// discovery/parser so both the scanner and the agent-parser dispatcher can +// import them without pulling in the SyncDatabase dependency. + +const OPENCODE_SQLITE_PATH_SEPARATOR = '#' + +/** + * Build a synthetic candidate path that encodes the SQLite DB path and session ID + * as `#`. Used by the discovery layer so SQLite-backed + * sessions flow through the same FileWithMtime pipeline as file-backed ones. + * @param dbPath - Absolute path to the opencode.db file. + * @param sessionId - The OpenCode session ID (primary key in the session table). + * @returns The synthetic candidate path string. + */ +export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` +} + +/** + * Parse a synthetic candidate path back into its DB path and session ID parts. + * Validates that the DB basename matches `opencode*.db` so real filesystem paths + * that happen to contain `#` are never misrouted to the SQLite parser. + * @param candidatePath - The synthetic path to parse. + * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. + */ +export function splitOpenCodeSqliteCandidate( + candidatePath: string +): { dbPath: string; sessionId: string } | null { + const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { + return null + } + const dbPath = candidatePath.slice(0, separatorIndex) + const sessionId = candidatePath.slice(separatorIndex + 1) + if (!dbPath || !sessionId) { + return null + } + // Why: OpenCode DB files are named opencode*.db; reject anything else so we + // never misroute a real filesystem path that happens to contain '#'. + if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) { + return null + } + return { dbPath, sessionId } +} + +/** + * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite + * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null). + * @param candidatePath - The path to test. + * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise. + */ +export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { + return splitOpenCodeSqliteCandidate(candidatePath) !== null +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts new file mode 100644 index 00000000000..96ca6ea361a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -0,0 +1,499 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + workspace_id TEXT, + path TEXT, + agent TEXT, + model TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT, + icon_url_override TEXT + ); + `) +} + +function applyMinimalOpenCodeSchema(db: Database.Database): void { + db.exec(`CREATE TABLE session ( + id TEXT PRIMARY KEY, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + );`) +} + +function insertSession( + db: Database.Database, + args: { + id: string + title?: string + directory?: string + timeCreated: number + timeUpdated: number + parentId?: string | null + timeArchived?: number | null + model?: string | null + agent?: string | null + tokensInput?: number + tokensOutput?: number + tokensReasoning?: number + tokensCacheRead?: number + cost?: number + } +): void { + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, time_archived, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0', + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, 0)` + ).run( + args.id, + args.parentId ?? null, + `slug-${args.id}`, + args.directory ?? '/tmp/opencode', + args.title ?? 'OpenCode title', + args.timeCreated, + args.timeUpdated, + args.timeArchived ?? null, + args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }), + args.agent ?? 'build', + args.cost ?? 0, + args.tokensInput ?? 100, + args.tokensOutput ?? 40, + args.tokensReasoning ?? 10, + args.tokensCacheRead ?? 5 + ) +} + +function insertMessage( + db: Database.Database, + args: { + id: string + sessionId: string + role: 'user' | 'assistant' + timeCreated: number + summaryTitle?: string | null + summaryBody?: string | null + } +): void { + const data = JSON.stringify({ + role: args.role, + time: { created: args.timeCreated }, + agent: 'build', + summary: + args.summaryTitle || args.summaryBody + ? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null } + : undefined + }) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?)` + ).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data) +} + +function insertPart( + db: Database.Database, + args: { + id: string + messageId: string + sessionId: string + timeCreated: number + type?: 'text' | 'tool' | 'reasoning' + text?: string + } +): void { + const data = JSON.stringify({ + type: args.type ?? 'text', + text: args.text ?? 'hello world' + }) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data) +} +describe('listOpenCodeSqliteSessions', () => { + it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_old', + title: 'Old', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_new', + title: 'New', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(2) + expect(candidates[0].agent).toBe('opencode') + expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new')) + expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old')) + }) + + it('dedups matching session ids across databases and keeps the newest row', async () => { + const { db: oldDb, path: oldPath } = createTempDb() + applyOpenCodeSchema(oldDb) + insertSession(oldDb, { + id: 'ses_duplicate', + title: 'Old duplicate', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + oldDb.close() + + const { db: newDb, path: newPath } = createTempDb() + applyOpenCodeSchema(newDb) + insertSession(newDb, { + id: 'ses_duplicate', + title: 'New duplicate', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + newDb.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [oldPath, newPath], + limit: 10, + issues: [] + }) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate')) + }) + it('excludes archived and child sessions', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_normal', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_archived', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_002_000, + timeArchived: 1_777_634_002_500 + }) + insertSession(db, { + id: 'ses_child', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_003_000, + parentId: 'ses_normal' + }) + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates.map((c) => c.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, 'ses_normal') + ]) + }) + + it('returns [] when the session table is missing (legacy install)', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates).toEqual([]) + }) + + it('records an issue when the DB file does not exist', async () => { + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: ['/nonexistent/opencode.db'], + limit: 10, + issues + }) + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + expect(issues[0].agent).toBe('opencode') + expect(issues[0].path).toBe('/nonexistent/opencode.db') + }) + + it('lists sessions from a minimal readable session table', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal')) + }) +}) + +describe('parseOpenCodeSqliteSession', () => { + it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_1', + title: 'OpenCode title', + directory: '/tmp/opencode', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + tokensInput: 100, + tokensOutput: 40, + tokensReasoning: 10, + tokensCacheRead: 5, + cost: 0.01 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_1', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryTitle: 'OpenCode title' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_1', + timeCreated: 1_777_634_000_500, + text: 'Plan the work' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_1', + role: 'assistant', + timeCreated: 1_777_634_000_900 + }) + insertPart(db, { + id: 'prt_2', + messageId: 'msg_2', + sessionId: 'ses_1', + timeCreated: 1_777_634_001_000, + text: 'Done' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.agent).toBe('opencode') + expect(session!.sessionId).toBe('ses_1') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode title') + expect(session!.cwd).toBe('/tmp/opencode') + expect(session!.model).toBe('glm-5.2') + expect(session!.totalTokens).toBe(150) + expect(session!.messageCount).toBe(2) + expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString()) + expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString()) + expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'") + expect(session!.previewMessages).toHaveLength(2) + expect(session!.previewMessages[0].text).toBe('Plan the work') + expect(session!.previewMessages[0].role).toBe('user') + expect(session!.previewMessages[1].text).toBe('Done') + expect(session!.previewMessages[1].role).toBe('assistant') + }) + + it('falls back to summary.body for title when session.title is empty', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_2', + title: '', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_2', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryBody: 'fallback title from summary' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_2', + timeCreated: 1_777_634_000_500, + text: 'hello' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_2', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.title).toBe('fallback title from summary') + }) + + it('returns null when the session id is not found', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_real', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_missing', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('returns null when the DB has no session table', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('parses a minimal readable session table without optional columns or messages', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_minimal', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.sessionId).toBe('ses_minimal') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode ses_mini') + expect(session!.cwd).toBeNull() + expect(session!.model).toBeNull() + expect(session!.messageCount).toBe(0) + expect(session!.totalTokens).toBe(0) + expect(session!.previewMessages).toEqual([]) + }) + + it('extracts model from older modelID schema', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_3', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + model: JSON.stringify({ modelID: 'claude-sonnet-4-5' }) + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_3', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.model).toBe('claude-sonnet-4-5') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts new file mode 100644 index 00000000000..f3a6dbcd055 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -0,0 +1,263 @@ +import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import { normalizeTitleText } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: OpenCode 1.17.x migrated session storage from per-session JSON files +// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module +// parses individual sessions from the DB into AiVaultSession objects. The +// discovery layer (listing candidates) lives in +// session-scanner-opencode-sqlite-discovery.ts. + +const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +type PreviewRow = { + role: string | null + part_data: string + time_created: number + summary_title: string | null + summary_body: string | null +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + // Why: belt-and-suspenders guard so a bug in the SELECT list can never + // mutate the user's opencode.db. + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionQuery(db: SyncDatabase): string { + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${sessionColumnSelect(db, 'model')} AS model_json, + ${sessionColumnSelect(db, 'agent')} AS agent, + ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, + ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, + ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, + ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, + ${sessionNumberColumnSelect(db, 'cost')} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE s.id = ? + LIMIT 1` +} + +function extractModelId(modelJson: string | null): string | null { + if (!modelJson) { + return null + } + try { + const parsed = JSON.parse(modelJson) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + // Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}. + // Older schemas used {"modelID":"..."}; accept both. + return ( + (typeof record.id === 'string' && record.id.trim()) || + (typeof record.modelID === 'string' && record.modelID.trim()) || + null + ) + } catch { + return null + } +} + +function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] { + if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') { + return role + } + return 'unknown' +} + +function extractPartText(partData: string): string | null { + try { + const parsed = JSON.parse(partData) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + if (typeof record.text === 'string') { + return record.text + } + return null + } catch { + return null + } +} + +function buildPreviewQuery(db: SyncDatabase): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + return `SELECT json_extract(m.data, '$.role') AS role, + p.data AS part_data, + p.time_created, + json_extract(m.data, '$.summary.title') AS summary_title, + json_extract(m.data, '$.summary.body') AS summary_body + FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') IN ('user','assistant') + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created DESC + LIMIT ?` +} + +/** + * Parse a single OpenCode session from the SQLite database into an + * `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost) + * and up to 5 preview messages by joining the `message` and `part` tables. + * The database is opened read-only with `PRAGMA query_only = ON` as a + * belt-and-suspenders guard against mutations. + * @param args.dbPath - Absolute path to the opencode.db file. + * @param args.sessionId - The session ID (primary key in the `session` table). + * @param args.platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if the session does not exist + * or the database lacks the required schema. + */ +export async function parseOpenCodeSqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + const { dbPath, sessionId, platform } = args + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } + + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) + + const previewSql = buildPreviewQuery(db) + if (previewSql) { + const previewRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[] + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue + } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue + } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } + } + } + + return finalizeSession(accumulator, platform) + } finally { + db?.close() + } +} diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index e5b5f7f371b..b4e376e606d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' 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' @@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) const GROK_SESSIONS_DIR = join( process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), 'sessions' @@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: { ...(options.additionalCodexSessionsDirs ?? []) ]) - return Promise.all([ + return Promise.all([ + // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a + // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) + // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. + ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), @@ -109,7 +110,6 @@ function standardDiscoveries( discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) ), ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...opencodeDiscoveries(options, wslHomeDirs, limit, issues), ...grokDiscoveries(options, wslHomeDirs, limit, issues), ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), @@ -139,21 +139,6 @@ function cursorDiscoveries( ) } -function opencodeDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs( - join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'), - wslHomeDirs, - ['.local', 'share', 'opencode', 'storage', 'session'] - ).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] }) - ) -} - function grokDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index e5330391f0e..fc7cf389b7d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,9 @@ export type AiVaultScanOptions = { copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string + // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB + // here so they don't depend on the real ~/.local/share/opencode. + opencodeDbPaths?: readonly string[] grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index ae0bc7ce106..d8afc36b7ee 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f07a769562d..484dbbc6339 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 +/** + * Scan all supported AI agent session stores and return a unified, sorted, + * deduplicated list of sessions for the AI Vault panel. Discovers sessions + * from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based + * stores (OpenCode 1.17.x). Results are sorted by session sort time DESC + * and truncated to `limit`. + * @param options - Optional scan configuration (limits, custom dirs, platform). + * @returns The list of sessions, scan issues, and a timestamp. + */ export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 83cc7f742d6..aaf998b3ca4 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' +import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { OpenCodeUsageAttributedEvent, @@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -function tableExists(db: Database.Database, tableName: string): boolean { - const row = db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(tableName) as { found?: number } | undefined - return row?.found === 1 -} - -function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] - return rows.some((row) => row.name === columnName) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/main/opencode-usage/schema-helpers.ts b/src/main/opencode-usage/schema-helpers.ts new file mode 100644 index 00000000000..d392eb58d7d --- /dev/null +++ b/src/main/opencode-usage/schema-helpers.ts @@ -0,0 +1,31 @@ +import type SyncDatabase from '../sqlite/sync-database' + +// Why: OpenCode's usage scanner and the AI Vault session scanner both need to +// probe the opencode.db schema shape across multiple DB generations. Centralizing +// the probes here avoids two private copies and keeps the contract testable. +type Database = SyncDatabase.Database + +/** + * Check whether a table exists in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table name to look up in sqlite_master. + * @returns `true` if the table exists, `false` otherwise. + */ +export function tableExists(db: Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +/** + * Check whether a column exists on a table in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table to inspect via PRAGMA table_info. + * @param columnName - The column name to find. + * @returns `true` if the column exists on the table, `false` otherwise. + */ +export function columnExists(db: Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} From 83f49818d31af46ba8375030b57d5bea405eb4cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:31:41 -0700 Subject: [PATCH 55/65] Detect remote agents from shell PATH (#6157) Co-authored-by: Orca --- src/main/ipc/agent-detection-shell-path.ts | 16 ++ src/main/ipc/preflight.test.ts | 59 ++++++ src/main/ipc/preflight.ts | 15 +- src/main/runtime/orca-runtime.test.ts | 28 +-- src/main/runtime/orca-runtime.ts | 5 +- .../runtime/rpc/methods/preflight.test.ts | 10 +- src/main/runtime/rpc/methods/preflight.ts | 4 +- src/relay/preflight-handler.test.ts | 199 +++++++++++++++++- src/relay/preflight-handler.ts | 176 ++++++++++++++-- 9 files changed, 458 insertions(+), 54 deletions(-) create mode 100644 src/main/ipc/agent-detection-shell-path.ts diff --git a/src/main/ipc/agent-detection-shell-path.ts b/src/main/ipc/agent-detection-shell-path.ts new file mode 100644 index 00000000000..42569755037 --- /dev/null +++ b/src/main/ipc/agent-detection-shell-path.ts @@ -0,0 +1,16 @@ +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' + +export async function hydrateShellPathForAgentDetection( + context?: PreflightRuntimeContext +): Promise { + if (getPreflightWslTarget(context)) { + return + } + // Why: remote runtime servers may inherit a sparse daemon/SSH PATH even + // though the user's shell can run the agents. + const hydration = await hydrateShellPath() + if (hydration.ok) { + mergePathSegments(hydration.segments) + } +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 94b969848b2..d3ffef854ef 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({ import { _resetPreflightCache, detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, registerPreflightHandlers, runPreflightCheck } from './preflight' @@ -105,6 +106,7 @@ describe('preflight', () => { handleMock.mockReset() execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() + hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() @@ -575,6 +577,63 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) }) + it('hydrates shell PATH before user-facing agent detection', async () => { + const originalPath = process.env.PATH + process.env.PATH = '/usr/bin' + hydrateShellPathMock.mockResolvedValueOnce({ + segments: ['/home/test/.local/bin'], + ok: true, + failureReason: 'none' + }) + mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => { + process.env.PATH = [...segments, '/usr/bin'].join(':') + return segments + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + try { + await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex']) + } finally { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } + expect(hydrateShellPathMock).toHaveBeenCalledWith() + expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin']) + }) + + it('does not run host shell hydration for 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]) + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect( + detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' }) + ).resolves.toEqual(['claude']) + expect(hydrateShellPathMock).not.toHaveBeenCalled() + }) + 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 8d65e0acc4b..fbe00db4605 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' +import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) } +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] @@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void { } ) - ipcMain.handle( - 'preflight:detectAgents', - async (_event, args?: PreflightRuntimeContext): Promise => { - return detectInstalledAgents(args) - } + ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentsWithShellPathHydration(args) ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 096c3ecf771..25dfa727a77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -164,7 +164,7 @@ const { addGitHubIssueCommentMock, listGitHubLabelsMock, listGitHubAssignableUsersMock, - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, listGitLabMergeRequestsMock, listGitLabWorkItemsMock, @@ -255,7 +255,7 @@ const { addGitHubIssueCommentMock: vi.fn(), listGitHubLabelsMock: vi.fn(), listGitHubAssignableUsersMock: vi.fn(), - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), @@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({ })) vi.mock('../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void { listGitHubLabelsMock.mockResolvedValue([]) listGitHubAssignableUsersMock.mockReset() listGitHubAssignableUsersMock.mockResolvedValue([]) - detectInstalledAgentsMock.mockReset() - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockReset() + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() detectRemoteAgentsMock.mockResolvedValue([]) listGitLabMergeRequestsMock.mockReset() @@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => { }) it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => { }) it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-fallback-draft', @@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => { }) it('lets explicit startup draft agents override the desktop default', async () => { - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} const runtimeStore = { ...store, @@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => { }) it('does not auto-launch an agent for startup drafts when the default is blank', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() @@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a0ca7efcadf..bd81e59fcc7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -172,7 +172,7 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' +import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -11436,9 +11436,10 @@ export class OrcaRuntimeService { if (!agent) { let detected: string[] = [] try { + // Why: startup-draft fallback can run from sparse runtime launch envs too. detected = repo.connectionId ? await detectRemoteAgents({ connectionId: repo.connectionId }) - : await detectInstalledAgents() + : await detectInstalledAgentsWithShellPathHydration() } catch { detected = [] } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 51a1231caa3..d28b97b8e53 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) vi.mock('../../../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock @@ -46,7 +46,7 @@ describe('preflight RPC methods', () => { }) it('detects agents and refreshes PATH on the server through runtime RPC', async () => { - detectInstalledAgentsMock.mockResolvedValueOnce(['codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({ agents: ['codex', 'claude'], addedPathSegments: ['/opt/bin'], @@ -60,7 +60,7 @@ describe('preflight RPC methods', () => { const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents')) const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents')) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled() expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 720a823cc22..4e0f06201e9 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, - detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../ipc/preflight' @@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ name: 'preflight.detectAgents', params: null, - handler: async () => detectInstalledAgents() + handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b2dfffe8ed2..6d2f9339b9e 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from 'vitest' -import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn() +})) + +vi.mock('child_process', () => { + const execFileWithPromisify = Object.assign(vi.fn(), { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock + }) + return { execFile: execFileWithPromisify } +}) + +import { + buildCommandLookupSpec, + buildCommandLookupSpecs, + hasAbsoluteCommandPath, + isCommandOnPathForRelay +} from './preflight-handler' + +function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { + return [ + mode, + [ + `if resolved=$(command -v ${command} 2>/dev/null); then`, + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'fi' + ].join('\n') + ] +} + +function fishLookupArgs(command: string): string[] { + return [ + '-ilc', + [ + `set -l resolved (command -v ${command} 2>/dev/null)`, + 'if test -n "$resolved"', + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'end' + ].join('\n') + ] +} + +beforeEach(() => { + execFileAsyncMock.mockReset() +}) describe('buildCommandLookupSpec', () => { it('uses where.exe on native Windows SSH hosts', () => { @@ -10,15 +54,160 @@ describe('buildCommandLookupSpec', () => { }) }) - it('passes the command as an argument to the POSIX login-shell probe', () => { - expect(buildCommandLookupSpec('codex', 'linux')).toEqual({ + it('falls back to sh for POSIX probes without a configured shell', () => { + expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: ['-lc', 'command -v "$1"', 'sh', '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') + }) + }) + + it('quotes command names in shell probes', () => { + expect( + buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') + ).toEqual({ + file: '/bin/bash', + args: lookupArgs("'agent'\\''cli'", '-ilc') + }) + }) +}) + +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'") } + ]) + }) + + it('allows a custom shell path only when the account login shell matches', () => { + expect( + buildCommandLookupSpecs( + 'codex', + 'darwin', + { SHELL: '/opt/homebrew/bin/zsh' }, + '/opt/homebrew/bin/zsh' + ) + ).toEqual([ + { 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') + }) + }) + + it('uses fish syntax for trusted fish shells', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({ + file: '/usr/bin/fish', + args: fishLookupArgs("'codex'") + }) + }) + + 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'") } + ]) + }) + + 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'") }]) + }) +}) + +describe('isCommandOnPathForRelay', () => { + it('falls back to inherited PATH when shell startup returns no absolute command path', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'welcome\ncodex is a function\n' }) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/zsh', PATH: '/usr/bin' }, + 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'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + }) + + it('falls back to inherited PATH when shell startup fails', async () => { + execFileAsyncMock + .mockRejectedValueOnce(new Error('startup failed')) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not execute an untrusted configured shell before inherited PATH lookup', async () => { + execFileAsyncMock.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/tmp/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/tmp/zsh' }), + timeout: 5000 }) }) }) describe('hasAbsoluteCommandPath', () => { + it('ignores banners and shell function output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe( + false + ) + }) + + it('ignores unmarked POSIX absolute paths from shell startup output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false) + }) + + it('recognizes a sentinel-marked command path amid shell startup and exit output', () => { + expect( + hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux') + ).toBe(true) + }) + it('recognizes Windows absolute command paths', () => { expect( hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index fb47248323d..a0382d77275 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { userInfo } from 'os' import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' @@ -12,6 +13,16 @@ type CommandLookupSpec = { windowsHide?: true } +type RelayCommandLookupOptions = { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + accountLoginShell?: string | null +} + +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__' + export class PreflightHandler { private dispatcher: RelayDispatcher @@ -43,35 +54,77 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } - // Why: SSH exec channels give the relay a minimal environment without - // .zprofile/.bash_profile sourced. Running `which` directly would miss - // agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login - // shell (`-lc`) ensures PATH matches what the user's PTY sessions see. - // Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there. + // Why: SSH exec channels give the relay a minimal environment without shell + // startup files sourced. Ask the user's configured shell so agent dirs added + // by zsh/bash/fish startup hooks match the remote terminal experience. + // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. private async isCommandOnPath(command: string): Promise { - try { - const spec = buildCommandLookupSpec(command, process.platform) - const { stdout } = await execFileAsync(spec.file, spec.args, { - encoding: 'utf-8', - env: buildRelayCommandEnv(), - timeout: 5000, - ...(spec.windowsHide ? { windowsHide: true } : {}) - }) - return hasAbsoluteCommandPath(stdout, process.platform) - } catch { - return false - } + return isCommandOnPathForRelay(command) } } export function buildCommandLookupSpec( command: string, - platform: NodeJS.Platform + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null ): CommandLookupSpec { + const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell) + return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh') +} + +export function buildCommandLookupSpecs( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null +): CommandLookupSpec[] { if (platform === 'win32') { - return { file: 'where.exe', args: [command], windowsHide: true } + return [{ file: 'where.exe', args: [command], windowsHide: true }] } - return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] } + const trustedShell = pickTrustedPosixShell( + env, + resolveAccountLoginShell(platform, accountLoginShell) + ) + const specs: CommandLookupSpec[] = [] + + if (trustedShell) { + specs.push(buildPosixCommandLookupSpec(command, trustedShell)) + } + + const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh') + if (!trustedShell || trustedShell !== inheritedPathSpec.file) { + specs.push(inheritedPathSpec) + } + + return specs +} + +export async function isCommandOnPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) + + for (const spec of specs) { + try { + const { stdout } = await execFileAsync(spec.file, spec.args, { + encoding: 'utf-8', + env: buildRelayCommandEnv(env, platform), + timeout: 5000, + ...(spec.windowsHide ? { windowsHide: true } : {}) + }) + if (hasAbsoluteCommandPath(stdout, platform)) { + return true + } + } catch { + // Try the inherited-PATH fallback before reporting the agent missing. + } + } + + return false } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { @@ -79,5 +132,86 @@ export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform return output .split(/\r?\n/) .map((line) => line.trim()) - .some((line) => pathOps.isAbsolute(line)) + .some((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) + }) +} + +function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { + const shellName = path.posix.basename(shell).toLowerCase() + if (shellName === 'fish') { + return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] } + } + return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] } +} + +function buildShCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'fi' + ].join('\n') +} + +function buildFishCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `set -l resolved (command -v ${quotedCommand} 2>/dev/null)`, + 'if test -n "$resolved"', + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'end' + ].join('\n') +} + +function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} + +function pickTrustedPosixShell( + env: NodeJS.ProcessEnv, + accountLoginShell: string | null +): string | null { + const shell = env.SHELL + if (!shell || !path.posix.isAbsolute(shell)) { + return null + } + const shellName = path.posix.basename(shell).toLowerCase() + if (!SUPPORTED_POSIX_SHELLS.has(shellName)) { + return null + } + if (accountLoginShell) { + return shell === accountLoginShell ? shell : null + } + return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null +} + +function getShellCommandMode(shell: string): '-lc' | '-ilc' { + const shellName = path.posix.basename(shell).toLowerCase() + // Why: bash/zsh/fish users commonly add package-manager bins from interactive + // startup files. POSIX sh/dash may not support interactive login flags. + return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc' +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` } From b8becaae39ad19fa77eaa21d6387b394b3591b55 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:08:19 -0700 Subject: [PATCH 56/65] Remove active-worktree gating from restored session visibility (#6185) Co-authored-by: Orca --- .../src/lib/worktree-runtime-owner.test.ts | 43 ++++ .../src/lib/worktree-runtime-owner.ts | 29 +++ .../src/runtime/web-session-tabs-sync.test.ts | 8 +- .../src/runtime/web-session-tabs-sync.ts | 216 +++++++++--------- .../slices/store-session-cascades.test.ts | 81 ++++++- src/renderer/src/store/slices/terminals.ts | 62 ++--- 6 files changed, 297 insertions(+), 142 deletions(-) diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 849cb4dfa34..35bb8eb522c 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -3,6 +3,7 @@ import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds, getSettingsForWorktreeRuntimeOwner, type WorktreeRuntimeOwnerState } from './worktree-runtime-owner' @@ -118,3 +119,45 @@ describe('getExplicitRuntimeEnvironmentIdForWorktree', () => { ).toBe('runtime:worktree-env') }) }) + +describe('getRuntimeSessionMirrorEnvironmentIds', () => { + it('includes focused runtime plus explicit repo, worktree, and folder owners', () => { + const multiRuntimeState: WorktreeRuntimeOwnerState = { + ...state, + worktreesByRepo: { + ...state.worktreesByRepo, + 'runtime-repo': [ + ...(state.worktreesByRepo?.['runtime-repo'] ?? []), + { + id: 'runtime-repo::wt-runtime-override', + repoId: 'runtime-repo', + hostId: 'runtime:worktree-env' + } + ] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(multiRuntimeState)).toEqual([ + 'focused-env', + 'folder-env', + 'owner-env', + 'worktree-env' + ]) + }) + + it('does not include local or SSH owners', () => { + const localOnlyState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'ssh-repo', connectionId: 'remote', executionHostId: 'ssh:remote' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-local', repoId: 'local-repo', hostId: 'local' }], + 'ssh-repo': [{ id: 'ssh-repo::wt-ssh', repoId: 'ssh-repo', hostId: 'ssh:remote' }] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e8c0e50f795..be54a7b9eb2 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -162,6 +162,35 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) } +export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwnerState): string[] { + const ids = new Set() + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + if (activeRuntimeEnvironmentId) { + ids.add(activeRuntimeEnvironmentId) + } + for (const repo of state.repos ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) + if (environmentId) { + ids.add(environmentId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + const environmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree.hostId) + if (environmentId) { + ids.add(environmentId) + } + } + } + for (const group of state.projectGroups ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(group.executionHostId) + if (environmentId) { + ids.add(environmentId) + } + } + return [...ids].sort() +} + export function getExecutionHostIdForWorktree( state: WorktreeRuntimeOwnerState, worktreeId: string | null | undefined 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 90293c7ec7f..687b18e24fa 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -306,19 +306,17 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(false) }) - it('only starts the all-session mirror for paired web clients', () => { + it('starts the all-session mirror for desktop and paired web clients', () => { expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: true + workspaceSessionReady: true }) ).toBe(true) expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: false + workspaceSessionReady: false }) ).toBe(false) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 43fc267001c..8ec5c326daf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -32,7 +32,10 @@ import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds +} from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, HOST_TERMINAL_SURFACE_SEPARATOR, @@ -255,10 +258,9 @@ export function shouldSyncRuntimeSessionTabs(args: { export function shouldSyncAllRuntimeSessionTabs(args: { activeRuntimeEnvironmentId: string | null | undefined workspaceSessionReady: boolean - isWebClient: boolean }): boolean { const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient) + return Boolean(environmentId && args.workspaceSessionReady) } export function resetWebSessionTabsSnapshotFreshnessForTests(): void { @@ -2328,134 +2330,142 @@ export function applyFreshWebSessionTabsSnapshots( export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const runtimeSessionMirrorEnvironmentKey = useAppStore((state) => + getRuntimeSessionMirrorEnvironmentIds(state).join('\u0000') ) const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true useEffect(() => { - const environmentId = activeRuntimeEnvironmentId?.trim() + const environmentIds = runtimeSessionMirrorEnvironmentKey + ? runtimeSessionMirrorEnvironmentKey.split('\u0000').filter((id) => id.trim()) + : [] // Why: startup hydration writes browser-local session state; applying the // host snapshot before that point gets clobbered and leaves the sidebar stale. - // Desktop clients should not mirror every remote session just because a - // remote is connected; project discovery runs through separate repo APIs. - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId, - workspaceSessionReady, - isWebClient - }) || - !environmentId - ) { + // Selectedness is not liveness: desktop and web clients both mirror the + // runtime's session bindings so background worktrees do not look asleep. + if (!workspaceSessionReady || environmentIds.length === 0) { return } let disposed = false - let unsubscribe: (() => void) | null = null + const unsubscribes: (() => void)[] = [] // Why: the streaming RPC emits an initial snapshots event, but startup can // render a paired web session before that event is applied. A one-shot // fetch makes initial parity deterministic; the stream remains the live // update path afterward. - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000 - }) - .then((response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) - ) - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - - void window.api.runtimeEnvironments - .subscribe( - { + for (const environmentId of environmentIds) { + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + void window.api.runtimeEnvironments + .call({ selector: environmentId, - method: 'session.tabs.subscribeAll', + method: 'session.tabs.listAll', params: {}, timeoutMs: 15_000 - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] global subscription failed:', - response.error.message - ) - return - } - const event = response.result as SessionTabsStreamEvent - if (event.type === 'snapshots') { - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) - ) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshot(state, event, environmentId) - ) - }, - onError: (error) => { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) + }) + .then((response: RuntimeRpcResponse) => { + if (disposed) { + return } - } - ) - .then((handle) => { - if (disposed) { - handle.unsubscribe() - return - } - unsubscribe = handle.unsubscribe - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) ) - } - }) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response: RuntimeRpcResponse) => { + if (disposed) { + return + } + if (response.ok === false) { + console.warn( + '[web-session-tabs-sync] global subscription failed:', + response.error.message + ) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type === 'snapshots') { + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) + ) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshot(state, event, environmentId) + ) + }, + onError: (error) => { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + ) + .then((handle) => { + if (disposed) { + handle.unsubscribe() + return + } + unsubscribes.push(handle.unsubscribe) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + } + }) + } return () => { disposed = true - unsubscribe?.() + for (const unsubscribe of unsubscribes) { + unsubscribe() + } // Why: environment ids can churn as paired runtimes reconnect or switch; // stale freshness/mapping entries should not live for the renderer lifetime. - clearWebSessionTabsTrackingForEnvironment(environmentId) + for (const environmentId of environmentIds) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } } - }, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady]) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) useEffect(() => { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index da3524afdcf..ed21ca8906c 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -101,6 +101,8 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test-helpers' +import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' // ─── Helpers ────────────────────────────────────────────────────────── @@ -1490,7 +1492,7 @@ describe('reconnectPersistedTerminals', () => { [wt2]: [makeTab({ id: 'tab2', worktreeId: wt2, ptyId: 'old-pty-2' })] }, terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() }, - activeWorktreeIdsOnShutdown: [wt1, wt2] + activeWorktreeIdsOnShutdown: [wt1] }) expect(store.getState().workspaceSessionReady).toBe(false) @@ -1508,9 +1510,22 @@ describe('reconnectPersistedTerminals', () => { expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - // Why: inactive worktrees keep a wake hint but must not advertise live PTYs - // until the user opens them and connectPanePty performs the actual reattach. - expect(s.ptyIdsByTabId.tab2).toEqual([]) + expect(s.ptyIdsByTabId.tab2).toEqual(['old-pty-2']) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1, wt2]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1801,6 +1816,64 @@ describe('reconnectPersistedTerminals', () => { expect(Object.values(bindings).sort()).toEqual(['daemon-session-A', 'daemon-session-B']) expect(s.workspaceSessionReady).toBe(true) }) + + it('advertises split-pane-only restored sessions for hide-sleeping visibility', async () => { + const store = createDaemonEnabledStore() + const wt1 = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: null })] + }, + terminalLayoutsByTabId: { + tab1: { + ...makeLayout(), + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:3' } + }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A', 'pane:3': 'daemon-session-B' } + } + }, + activeWorktreeIdsOnShutdown: [] + }) + + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + expect(s.ptyIdsByTabId.tab1?.sort()).toEqual(['daemon-session-A', 'daemon-session-B']) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1]) + }) }) describe('hydrateEditorSession', () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e235f65673f..7f62c0891ce 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,15 +2595,22 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - // Why: fall back to deriving the list from tabsByWorktree ptyIds when - // activeWorktreeIdsOnShutdown is absent (upgrade from older build). - // The raw tabs still carry ptyId values before clearTransientTerminalState - // nulls them, so we can infer which worktrees had active terminals. - const shutdownIds = - session.activeWorktreeIdsOnShutdown ?? - Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some((t) => t.ptyId)) - .map(([wId]) => wId) + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} + // Why: activeWorktreeIdsOnShutdown is only the eager-remount hint. Any + // tab with a persisted PTY or relay session can still reattach when + // opened, so it must also advertise liveness to Hide sleeping. + const tabHasRestorableSession = (tab: TerminalTab): boolean => { + const leafPtyIds = Object.values( + session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {} + ).filter(Boolean) + return Boolean(tab.ptyId || remoteSessionIds[tab.id] || leafPtyIds.length > 0) + } + const sessionBackedWorktreeIds = Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some(tabHasRestorableSession)) + .map(([wId]) => wId) + const shutdownIds = Array.from( + new Set([...(session.activeWorktreeIdsOnShutdown ?? []), ...sessionBackedWorktreeIds]) + ) const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) // Why: capture which specific tabs had live PTYs per worktree from the @@ -2613,12 +2620,11 @@ export const createTerminalSlice: StateCreator // Also include tabs whose relay session IDs were preserved in // remoteSessionIdsByTabId — those tabs were disconnected before shutdown // (ptyId was null) but the relay still has their PTY alive. - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} const pendingReconnectTabByWorktree: Record = {} for (const worktreeId of pendingReconnectWorktreeIds) { const rawTabs = session.tabsByWorktree[worktreeId] ?? [] const liveTabIds = rawTabs - .filter((t) => (t.ptyId || remoteSessionIds[t.id]) && validTabIds.has(t.id)) + .filter((t) => tabHasRestorableSession(t) && validTabIds.has(t.id)) .map((t) => t.id) if (liveTabIds.length > 0) { pendingReconnectTabByWorktree[worktreeId] = liveTabIds @@ -2794,8 +2800,7 @@ export const createTerminalSlice: StateCreator pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, terminalLayoutsByTabId, - tabsByWorktree, - activeWorktreeId + tabsByWorktree } = get() const ids = pendingReconnectWorktreeIds ?? [] @@ -2868,16 +2873,17 @@ export const createTerminalSlice: StateCreator console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) - if (tabLevelPtyId) { - const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId + if (tabLevelPtyId || hasLeafMappings) { set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) + if (tabLevelPtyId) { + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) + } // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2885,20 +2891,16 @@ export const createTerminalSlice: StateCreator // appear as orphans until the terminal pane mounts. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : [tabLevelPtyId] + : [tabLevelPtyId!] return { tabsByWorktree: next, - ...(shouldAdvertiseLivePtys - ? { - // Why: inactive worktrees only need wake hints. Publishing - // their PTYs as live at startup starts global session/status - // machinery before the user opens that workspace. - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: allPtyIds - } - } - : {}) + // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. + // Restored daemon sessions are still running even before their + // pane remounts, so background workspaces must advertise them. + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: allPtyIds + } } }) } From dd421a054bf7c725fbcc7c150f2b6a2688d47f1b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:19:15 -0700 Subject: [PATCH 57/65] Fix Codex session history titles from injected context (#6151) Co-authored-by: Orca --- .../ai-vault/session-scanner-agent-parser.ts | 7 +- .../session-scanner-codex-parser.test.ts | 88 ++++++ .../ai-vault/session-scanner-codex-parser.ts | 273 ++++++++++++++++++ .../session-scanner-primary-parsers.ts | 139 +-------- .../session-scanner-text-normalization.ts | 2 +- src/main/ai-vault/session-scanner.test.ts | 13 +- 6 files changed, 376 insertions(+), 146 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-codex-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-codex-parser.ts diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 385b2961984..adfb2aa86db 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -9,11 +9,8 @@ import { import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' -import { - parseClaudeSessionFile, - parseCodexSessionFile, - parseGeminiSessionFile -} from './session-scanner-primary-parsers' +import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers' +import { parseCodexSessionFile } from './session-scanner-codex-parser' import { parseCopilotSessionFile, parseCursorSessionFile, diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts new file mode 100644 index 00000000000..19581fc8761 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexSessionFile } from './session-scanner-codex-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('parseCodexSessionFile', () => { + it('does not double-count usage when token count formats switch', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'token-format-switch', cwd: '/repo/app' } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Measure Codex token totals' }] + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 70, + cached_input_tokens: 20, + output_tokens: 30, + total_tokens: 100 + } + } + } + }, + { + timestamp: '2026-06-18T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 90, + cached_input_tokens: 25, + output_tokens: 60, + total_tokens: 150 + } + } + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session?.totalTokens).toBe(150) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts new file mode 100644 index 00000000000..5435b2eaf1c --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -0,0 +1,273 @@ +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types' +import { + asRecord, + extractContentText, + extractGitBranch, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage +} from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +type CodexSessionIndexTitleCacheEntry = { + signature: string + titles: Map +} + +const codexSessionIndexTitleCache = new Map>() + +export async function parseCodexSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + codexHome: string | null = null +): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file, + sessionId: sessionIdFromFileName(file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes internal worker/sub-agent transcripts into the same + // history tree; AI Vault should show user-started sessions only. + return null + } + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const indexedTitle = + extractCodexSessionMetadataTitle(payload) ?? + (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + if (indexedTitle) { + accumulator.title = indexedTitle + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + continue + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + continue + } + + if (!payload) { + continue + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + continue + } + + if (record.type !== 'event_msg') { + continue + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + continue + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + continue + } + + if (payload.type !== 'token_count') { + continue + } + + const info = asRecord(payload.info) + if (!info) { + continue + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, previousTotals) + previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + } + + return finalizeSession(accumulator, platform, { codexHome }) +} + +function addCodexUsage( + base: CodexUsageSnapshot, + increment: CodexUsageSnapshot +): CodexUsageSnapshot { + return { + inputTokens: base.inputTokens + increment.inputTokens, + cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, + outputTokens: base.outputTokens + increment.outputTokens, + reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, + totalTokens: base.totalTokens + increment.totalTokens + } +} + +function extractCodexThreadSource(payload: Record): string | null { + return extractString(payload.thread_source) ?? extractString(payload.threadSource) +} + +function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractCodexThreadSource(payload) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + + const source = asRecord(payload.source) + return Boolean(asRecord(source?.subagent)) +} + +function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} + +async function readCodexSessionIndexTitle( + sessionFilePath: string, + codexHome: string | null, + sessionId: string +): Promise { + const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) + if (!resolvedCodexHome) { + return null + } + const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) + return titleBySessionId.get(sessionId) ?? null +} + +function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { + let currentDir = dirname(sessionFilePath) + while (currentDir && dirname(currentDir) !== currentDir) { + if (basename(currentDir) === 'sessions') { + return dirname(currentDir) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCodexSessionIndexTitles(codexHome: string): Promise> { + const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) + let signature: string + try { + const indexStat = await stat(indexPath) + signature = `${indexStat.size}:${indexStat.mtimeMs}` + } catch { + return new Map() + } + + const cached = codexSessionIndexTitleCache.get(codexHome) + if (cached) { + const entry = await cached + if (entry.signature === signature) { + return entry.titles + } + } + + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ + signature, + titles + })) + codexSessionIndexTitleCache.set(codexHome, pending) + return (await pending).titles +} + +async function readCodexSessionIndexTitlesFromDisk( + indexPath: string +): Promise> { + const titleBySessionId = new Map() + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex creates the index opportunistically; older homes may only have raw transcripts. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index fced9ab1f6a..dc062f5ab3e 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'fs' import { readFile } from 'fs/promises' import { createInterface } from 'readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, createAccumulator, @@ -16,14 +16,10 @@ import { asRecord, claudeUsageTotal, extractContentText, - extractGitBranch, extractMessageText, - extractModel, extractString, - normalizeCodexUsage, normalizeTitleText, parseJsonObject, - subtractCodexUsage, tokenTotal } from './session-scanner-values' @@ -99,139 +95,6 @@ export async function parseClaudeSessionFile( return finalizeSession(accumulator, platform) } -export async function parseCodexSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform, - codexHome: string | null = null -): Promise { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - return null - } - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - continue - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - continue - } - - if (!payload) { - continue - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - continue - } - - if (record.type !== 'event_msg') { - continue - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - continue - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - continue - } - - if (payload.type !== 'token_count') { - continue - } - - const info = asRecord(payload.info) - if (!info) { - continue - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage - if (totalUsage) { - previousTotals = totalUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - } - - return finalizeSession(accumulator, platform, { codexHome }) -} - -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - export async function parseGeminiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 8bd58ad6407..88eae73eb2e 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024 const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512 const FIELD_BLANK_SCAN_LIMIT = 1024 -const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for' +const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions' const XML_INSTRUCTIONS_PREFIX = '' const HIDDEN_TEXT_BLOCKS = [ diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d8afc36b7ee..a4c5cf6540f 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -113,7 +113,7 @@ describe('scanAiVaultSessions', () => { type: 'message', role: 'user', content: [ - { type: 'text', text: '# AGENTS.md instructions for /repo/app ' } + { type: 'text', text: '# AGENTS.md instructions\n\nrepo policy' } ] } }), @@ -165,6 +165,15 @@ describe('scanAiVaultSessions', () => { }) ].join('\n') ) + await writeFile( + join(root, 'session_index.jsonl'), + jsonLines([ + { + id: '019f0000-1111-7222-8333-444444444444', + thread_name: 'Indexed Codex resume picker title' + } + ]) + ) const result = await scanAiVaultSessions({ ...roots, @@ -174,7 +183,7 @@ describe('scanAiVaultSessions', () => { expect(result.issues).toEqual([]) expect(result.sessions).toHaveLength(2) expect(result.sessions.map((session) => session.title).sort()).toEqual([ - 'Fix the resume picker filters', + 'Indexed Codex resume picker title', 'Vault polish pass' ]) From ab04b1b99b21e8dc287890e1bfc0854f5c2078d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:20:18 +0000 Subject: [PATCH 58/65] release: v1.4.94-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad72559af63..55ea1c9f61d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.0", + "version": "1.4.94-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0797966d392627c220b27a0822e00f076c809eb7 Mon Sep 17 00:00:00 2001 From: AJ <60591772+devajmeireles@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:50:00 -0300 Subject: [PATCH 59/65] Preserve unsaved diff-note draft on outside click (#6188) * Preserve unsaved diff-note draft on outside click Clicking outside the diff-note popover while it held unsaved text discarded the draft. Dismiss-on-outside-click now only fires when the draft is empty or whitespace-only; with content the popover stays open so an accidental click can't lose an in-progress note. Escape and the Cancel button still dismiss explicitly. Mirror the draft into a ref so the document mousedown listener (empty dependency array) reads the freshest body, matching the existing onCancelRef/topRef pattern in the component. * Preserve large diff-note drafts on outside click Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../diff-comments/DiffCommentPopover.tsx | 15 +++ ...iff-comment-popover-outside-click.test.tsx | 125 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx index efe3ec78b42..51b376ade15 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -34,6 +34,10 @@ type Props = { onSubmit: (body: string) => Promise } +function hasDraftText(body: string): boolean { + return /\S/u.test(body) +} + export function DiffCommentPopover({ lineNumber, startLine, @@ -48,6 +52,12 @@ export function DiffCommentPopover({ onSubmit }: Props): React.JSX.Element { const [body, setBody] = useState('') + // Why: mirror the draft into a ref so the document mousedown listener (empty + // dep array, see below) can read the freshest body without re-registering on + // every keystroke. Used to keep the popover open on outside-click when the + // user has typed something, so an accidental click never discards a draft. + const bodyRef = useRef(body) + bodyRef.current = body // Why: `submitting` prevents duplicate note rows when the user // double-clicks the Add note button or hits Enter twice before the // IPC round-trip resolves. Iteration 1 made submission async and keeps the @@ -144,6 +154,11 @@ export function DiffCommentPopover({ if (popoverRef.current.contains(ev.target as Node)) { return } + // Why: outside-click is a soft dismiss, so preserve any non-whitespace + // draft even when submit's bounded scanner would reject it as too large. + if (hasDraftText(bodyRef.current)) { + return + } // Why: read the latest onCancel from the ref rather than closing over it // so the listener does not need to be re-registered on every parent // render (see onCancelRef comment above). diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx b/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx new file mode 100644 index 00000000000..a74e737fbe4 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx @@ -0,0 +1,125 @@ +// @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 { COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES } from '@/lib/comment-body-submit-state' +import { DiffCommentPopover } from './DiffCommentPopover' + +const roots: Root[] = [] + +async function renderPopover(args: { + onCancel: () => void + onSubmit?: (body: string) => Promise +}): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render( + {})} + /> + ) + }) + + return container +} + +// Why: the textarea is a controlled React input, so writing `.value` directly is +// invisible to React. Drive the change through the native value setter + an +// 'input' event the way React's synthetic onChange listens for it. +function typeInDraft(container: HTMLElement, value: string): void { + const textarea = container.querySelector('textarea') + if (!textarea) { + throw new Error('textarea not found') + } + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(textarea, value) + textarea.dispatchEvent(new Event('input', { bubbles: true })) +} + +async function clickOutside(): Promise { + await act(async () => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + }) +} + +describe('DiffCommentPopover outside-click draft preservation', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + vi.clearAllMocks() + }) + + it('keeps the popover open on outside-click when the draft has content', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, 'unsaved note') + }) + await clickOutside() + + expect(onCancel).not.toHaveBeenCalled() + }) + + it('dismisses on outside-click when the draft is empty', async () => { + const onCancel = vi.fn() + await renderPopover({ onCancel }) + + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('dismisses on outside-click when the draft is only whitespace', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, ' ') + }) + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('keeps the popover open when non-whitespace text follows large leading whitespace', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, `${' '.repeat(COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES + 1)}note`) + }) + await clickOutside() + + expect(onCancel).not.toHaveBeenCalled() + }) + + it('still dismisses after the user clears a draft back to empty', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, 'typed then deleted') + }) + await act(async () => { + typeInDraft(container, '') + }) + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) From ffa80eb5bb1f868cd93ce9a57f103f7ee527c82c Mon Sep 17 00:00:00 2001 From: Ricardo Sawir <37329575+sawirricardo@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:00:09 +0700 Subject: [PATCH 60/65] Fix Windows directory cwd prefixes (#6116) --- src/main/providers/windows-shell-args.test.ts | 13 +++++++++++++ src/main/providers/windows-shell-args.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index c9f6d1a470a..e7e8770a89c 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -214,6 +214,19 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\project') }) + it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected sh cmd must not break out of the surrounding single quotes diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 826b672d271..0e3d181fdd4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -136,6 +136,7 @@ export function resolveWindowsShellLaunchArgs( ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() const nativeCwd = normalizeMsysDrivePath(cwd) + const isMsysDriveCwd = nativeCwd !== cwd if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -183,7 +184,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, From b4d2e9810d9b1a6c722513e74595889a2ae317e2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:12:47 -0700 Subject: [PATCH 61/65] Restore pre-culprit workspace liveness boundary (#6196) Co-authored-by: Orca --- .../slices/store-session-cascades.test.ts | 17 +++++---- src/renderer/src/store/slices/terminals.ts | 37 +++++++------------ 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index ed21ca8906c..d2f93e73ab4 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1498,7 +1498,7 @@ describe('reconnectPersistedTerminals', () => { expect(store.getState().workspaceSessionReady).toBe(false) expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() expect(store.getState().tabsByWorktree[wt2][0].ptyId).toBeNull() - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1, wt2]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) await store.getState().reconnectPersistedTerminals() @@ -1508,9 +1508,9 @@ describe('reconnectPersistedTerminals', () => { // records daemon session IDs as tab-level ptyIds so connectPanePty // can pass them as sessionId to the daemon's createOrAttach. expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') - expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') + expect(s.tabsByWorktree[wt2][0].ptyId).toBeNull() expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - expect(s.ptyIdsByTabId.tab2).toEqual(['old-pty-2']) + expect(s.ptyIdsByTabId.tab2).toEqual([]) expect( computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { filterRepoIds: [], @@ -1525,7 +1525,7 @@ describe('reconnectPersistedTerminals', () => { defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {} }) - ).toEqual([wt1, wt2]) + ).toEqual([wt1]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1817,7 +1817,7 @@ describe('reconnectPersistedTerminals', () => { expect(s.workspaceSessionReady).toBe(true) }) - it('advertises split-pane-only restored sessions for hide-sleeping visibility', async () => { + it('does not advertise split-pane-only wake hints as hide-sleeping activity', async () => { const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' @@ -1852,12 +1852,13 @@ describe('reconnectPersistedTerminals', () => { activeWorktreeIdsOnShutdown: [] }) - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([]) await store.getState().reconnectPersistedTerminals() const s = store.getState() - expect(s.ptyIdsByTabId.tab1?.sort()).toEqual(['daemon-session-A', 'daemon-session-B']) + expect(s.terminalLayoutsByTabId.tab1?.ptyIdsByLeafId).toBeDefined() + expect(s.ptyIdsByTabId.tab1).toEqual([]) expect( computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { filterRepoIds: [], @@ -1872,7 +1873,7 @@ describe('reconnectPersistedTerminals', () => { defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {} }) - ).toEqual([wt1]) + ).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7f62c0891ce..b99b248a503 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,22 +2595,14 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} - // Why: activeWorktreeIdsOnShutdown is only the eager-remount hint. Any - // tab with a persisted PTY or relay session can still reattach when - // opened, so it must also advertise liveness to Hide sleeping. - const tabHasRestorableSession = (tab: TerminalTab): boolean => { - const leafPtyIds = Object.values( - session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {} - ).filter(Boolean) - return Boolean(tab.ptyId || remoteSessionIds[tab.id] || leafPtyIds.length > 0) - } - const sessionBackedWorktreeIds = Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some(tabHasRestorableSession)) - .map(([wId]) => wId) - const shutdownIds = Array.from( - new Set([...(session.activeWorktreeIdsOnShutdown ?? []), ...sessionBackedWorktreeIds]) - ) + // Why: match the pre-idle-runtime-optimization startup contract. + // activeWorktreeIdsOnShutdown is authoritative when present; persisted + // tab/layout PTY IDs are wake hints, not a broader active-workspace list. + const shutdownIds = + session.activeWorktreeIdsOnShutdown ?? + Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some((t) => t.ptyId)) + .map(([wId]) => wId) const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) // Why: capture which specific tabs had live PTYs per worktree from the @@ -2620,11 +2612,12 @@ export const createTerminalSlice: StateCreator // Also include tabs whose relay session IDs were preserved in // remoteSessionIdsByTabId — those tabs were disconnected before shutdown // (ptyId was null) but the relay still has their PTY alive. + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} const pendingReconnectTabByWorktree: Record = {} for (const worktreeId of pendingReconnectWorktreeIds) { const rawTabs = session.tabsByWorktree[worktreeId] ?? [] const liveTabIds = rawTabs - .filter((t) => tabHasRestorableSession(t) && validTabIds.has(t.id)) + .filter((t) => (t.ptyId || remoteSessionIds[t.id]) && validTabIds.has(t.id)) .map((t) => t.id) if (liveTabIds.length > 0) { pendingReconnectTabByWorktree[worktreeId] = liveTabIds @@ -2873,17 +2866,12 @@ export const createTerminalSlice: StateCreator console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) - if (tabLevelPtyId || hasLeafMappings) { + if (tabLevelPtyId) { set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - if (tabLevelPtyId) { - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) - } // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2892,6 +2880,9 @@ export const createTerminalSlice: StateCreator const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) : [tabLevelPtyId!] + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) return { tabsByWorktree: next, // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. From 02e5464d2a78419170f928829402b6ba7d76cf9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:13:45 +0000 Subject: [PATCH 62/65] release: v1.4.94-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55ea1c9f61d..1960a32566d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.1", + "version": "1.4.94-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From eb650d6cb8409416e685bf793661ac75aa5f0673 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:22:51 -0700 Subject: [PATCH 63/65] Make mobile pairing QR codes larger (#6192) Co-authored-by: Orca --- src/renderer/src/assets/mobile-page.css | 14 ++++++++++++-- src/renderer/src/components/mobile/MobileHero.tsx | 4 ++-- 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 ++- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 24ab4c3425b..df32f64ccae 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -557,6 +557,15 @@ height: 116px; } +.mobile-page-root .mp-qr-large { + padding: 10px; +} + +.mobile-page-root .mp-qr-large img { + width: 164px; + height: 164px; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -704,8 +713,9 @@ } .mobile-page-root .mp-network-select { - min-width: 220px; - max-width: 280px; + flex: 1 1 180px; + min-width: 0; + max-width: 240px; } .mobile-page-root .mp-network-refresh { diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 43409635549..1d7ac73c013 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -121,7 +121,7 @@ export function HeroFlow({
Date: Tue, 23 Jun 2026 13:37:17 -0700 Subject: [PATCH 64/65] feat: floating workspace shortcuts and tab shortcut affordances (#6077) * feat: add floating workspace shortcuts * test: shim localStorage in happy-dom tests * refactor: tighten floating shortcut tests and docs - Drop unrelated localStorage test shim (local Node 25 only; CI runs Node 24) - Add why-comments for minify/minimize macOS-only and unbound defaults - Assert minify bounds via getMinifiedFloatingTerminalBounds() not magic numbers - Cover out-of-range tab index and no-active-tab rename edge cases * test: cover custom floating shortcut bindings on Linux/Windows Proves user-assigned overrides for floatingWorkspace.maximize/minify fire on platforms where the default bindings are intentionally empty. * feat: show keybinding hints in floating panel control tooltips Maximize/restore and minimize tooltips now surface their live shortcut as "Label (shortcut)", matching the repo convention (e.g. ReviewPRViewAnimatedVisual). Degrades to a bare label when the action is unbound (Win/Linux defaults, and minimize on every platform). * feat: rework floating panel resize shortcut to Cmd+Opt+Shift+A - Move maximize/restore default from Cmd+Opt+Up to Cmd+Opt+Shift+A so it shares the Cmd+Opt+A toggle anchor and stays one-handed. - Open-into-maximized: pressing the chord while the panel is closed opens it straight into the maximized state (one-shot intent bridges the closed->mounted gap), regardless of last saved size. While open it remains a maximize<->restore toggle returning to saved dimensions. - Drop the minify action and its Cmd+Opt+Down default entirely; it was introduced earlier in this PR and is not in main, so this trims unshipped surface rather than removing an existing feature. Maximize tooltip now surfaces the new chord. Adds panel tests for the open-maximized intent and updates keybinding default tests. * feat: surface tab shortcut affordances * refactor: address 5.5 Pro review on tab shortcut affordances - Add Darwin Option-composed (Cmd+Opt+Shift+A) maximize capture/round-trip regression - Replace 'Unassigned' string-sentinel checks with useOptionalShortcutLabel (string | null) - Add TTL guard + unit tests so a stale open-maximized intent cannot leak into a later open * feat: show default agent shortcut in new tab menu * fix: address PR review on tab tooltip a11y, i18n, and test guards * Fix floating workspace shortcut edge cases Co-authored-by: Orca --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/renderer/src/App.tsx | 24 +- .../FloatingTerminalPanel.test.tsx | 465 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 206 ++++++-- .../FloatingTerminalWindowControls.tsx | 38 +- .../src/components/tab-bar/EditorFileTab.tsx | 29 +- .../tab-bar/EditorFileTabCloseButton.tsx | 56 +++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 52 +- .../tab-bar/EditorFileTabContextMenu.tsx | 11 +- .../tab-bar/QuickLaunchButton.test.ts | 155 +++++- .../components/tab-bar/QuickLaunchButton.tsx | 11 +- .../SortableTab.rename-shortcut.test.tsx | 10 +- .../src/components/tab-bar/SortableTab.tsx | 84 ++-- .../tab-bar/SortableTabContextMenu.test.tsx | 3 +- .../tab-bar/SortableTabContextMenu.tsx | 6 +- .../tab-bar/TabBar.context-menu.test.ts | 2 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../tab-bar/tab-title-tooltip.test.tsx | 14 + src/renderer/src/hooks/useShortcutLabel.ts | 20 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/lib/floating-terminal.test.ts | 45 ++ src/renderer/src/lib/floating-terminal.ts | 27 + src/shared/keybindings.test.ts | 102 ++++ src/shared/keybindings.ts | 53 ++ 27 files changed, 1313 insertions(+), 141 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx create mode 100644 src/renderer/src/lib/floating-terminal.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aacb0a5c9e5..3bcbf8887c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -1395,6 +1398,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1409,6 +1413,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1426,6 +1431,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1521,6 +1527,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 8306c7b9102..c571d145808 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,7 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { KeybindingOverrides } from '../../../../shared/keybindings' +import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -14,6 +14,10 @@ import { getMaximizedFloatingTerminalBounds, type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' type EffectCallback = () => void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -57,6 +62,7 @@ type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -65,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f91eded8216..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -58,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -94,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -376,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -963,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 950378ae1d1..514402a0f36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -16,6 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = { const controlButtonClassName = 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' +// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of +// surfacing the live keybinding in a tooltip as "Label (shortcut)", while +// degrading to a bare label when the action is unbound (default on Win/Linux, +// and for minimize on every platform). +function withShortcutHint(label: string, shortcutLabel: string | null): string { + return shortcutLabel ? `${label} (${shortcutLabel})` : label +} + export function FloatingTerminalWindowControls({ maximized, onToggleMaximized, @@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 6694b357ddb..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,28 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !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' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('
', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} 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 b47ec987e66..b22e1912d9e 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 @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !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')} @@ -178,6 +181,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/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0455c4fb64b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a699f1d5812..86a665eafb7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2624,6 +2625,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac7092448ff..f4592d35d23 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2624,6 +2625,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2ceb16b354d..205a953ec87 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7a22cac315f..60f7ac0ebbb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2624,6 +2625,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d2d12868b6..0c5d2d3c9fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..83db7e37efd 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,65 @@ describe('keybindings', () => { ) }) + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +609,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..57a8b775267 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -50,6 +50,8 @@ export type KeybindingActionId = | 'sidebar.ports.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In', From 30a1a52459bcfe7ed0acb4e4e6d80ee0eba23629 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:51:54 +0000 Subject: [PATCH 65/65] release: v1.4.94 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1960a32566d..6b08ed1e086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.2", + "version": "1.4.94", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai",