From 234caecb822a35c5b547561492fd2750f041decd Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 11 Aug 2026 16:10:15 -0700 Subject: [PATCH 001/109] fix(windows): refresh PATH ordering for newly installed tools (#13545) * fix(windows): refresh persisted PATH ordering New terminals now preserve Orca-injected entries while adopting current machine and user PATH precedence, so newly installed tools are not shadowed by stale aliases. Invalidate the cached registry snapshot on Windows setting changes and guard in-flight refreshes from restoring stale cache data. * fix(windows): retry invalidated PATH reads * fix(windows): reuse current PATH refresh * fix(windows): preserve per-hive PATH fallback state --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/ipc/pty.test.ts | 62 ++++ src/main/pty/windows-environment-path.test.ts | 323 +++++++++++++++++- src/main/pty/windows-environment-path.ts | 132 +++---- .../pty/windows-path-registry-change.test.ts | 32 ++ src/main/pty/windows-path-registry-change.ts | 19 ++ .../windows-path-registry-fallback.test.ts | 55 +++ .../pty/windows-path-registry-fallback.ts | 33 ++ .../pty/windows-path-registry-reader.test.ts | 14 +- src/main/pty/windows-path-registry-reader.ts | 10 +- src/main/pty/windows-path-segment-merge.ts | 83 +++++ src/main/window/createMainWindow.ts | 2 + 11 files changed, 673 insertions(+), 92 deletions(-) create mode 100644 src/main/pty/windows-path-registry-change.test.ts create mode 100644 src/main/pty/windows-path-registry-change.ts create mode 100644 src/main/pty/windows-path-registry-fallback.test.ts create mode 100644 src/main/pty/windows-path-registry-fallback.ts create mode 100644 src/main/pty/windows-path-segment-merge.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index cb8908e5c8f..1a75fcc70ff 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -238,6 +238,7 @@ import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, + buildPtyHostEnv, registerSshPtyProvider, clearPtyOwnershipForConnection, clearProviderPtyState, @@ -257,6 +258,8 @@ import { restorePtyIncarnation, type PrepareCodexSessionResume } from './pty' +import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { _resetHiddenRendererPtyDeliveryGateForTest, @@ -1952,6 +1955,65 @@ describe('registerPtyHandlers', () => { } describe('spawn environment', () => { + it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { + type: 1, + value: + root === 1 + ? 'C:\\Windows\\System32' + : 'C:\\Python314;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + } + }) + })) + __resetPersistedWindowsPathCacheForTests() + + try { + const provider = new LocalPtyProvider({ + buildSpawnEnv: (id, baseEnv, context) => + buildPtyHostEnv(id, baseEnv, { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + githubAttributionEnabled: false, + agentStatusHooksEnabled: false, + isWsl: context?.isWsl, + wslDistro: context?.wslDistro + }) + }) + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + env: { + PATH: 'C:\\Orca\\bin;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps', + WSLENV: 'ORCA_TERMINAL_HANDLE/u' + } + }) + const [file, , options] = spawnMock.mock.calls.at(-1)! + + expect(file).toBe('wsl.exe') + expect(options.env.PATH).toBe( + 'C:\\Orca\\bin;C:\\Windows\\System32;C:\\Python314;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + ) + const forwardedKeys = options.env.WSLENV.split(':').map((entry) => + entry.split('/')[0]!.toLowerCase() + ) + expect(options.env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') + expect(forwardedKeys).not.toContain('path') + } finally { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('publishes a lifecycle signal after a successful renderer spawn', async () => { await spawnAndGetEnv() diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index e1ace3350ca..4aaa7f91bdd 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -12,6 +12,7 @@ vi.mock('node:child_process', () => ({ import { __resetPersistedWindowsPathCacheForTests, + invalidatePersistedWindowsPathCache, mergePersistedWindowsPath, mergePersistedWindowsPathAsync, readPersistedWindowsPathSegments, @@ -127,6 +128,165 @@ describe('readPersistedWindowsPathSegments', () => { } }) + it('invalidates the cache after Windows reports an environment change', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\NewUser']) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('combines a fresh hive with the other hive last known good after invalidation', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\OldUser']) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('combines a fresh user hive with the machine hive last known good', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\NewUser']) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('withholds a partial first registry snapshot instead of changing PATH precedence', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const env = { Path: 'C:\\UserAlias;C:\\Existing' } + + mergePersistedWindowsPath(env) + + expect(env.Path).toBe('C:\\UserAlias;C:\\Existing') + expect(readPersistedWindowsPathSegments()).toEqual([]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('withholds the reverse partial first snapshot', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('C:\\User')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual([]) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not duplicate an unresolved asynchronous refresh for a synchronous caller', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const refresh = readPersistedWindowsPathSegmentsAsync() + + expect(readPersistedWindowsPathSegments()).toEqual([]) + await expect(refresh).resolves.toEqual([]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not let injected test reads populate production fallback state', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\InjectedMachine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\InjectedUser\r\n') + registryGetKeyMock.mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments({ platform: 'win32', execFileSync })).toEqual([ + 'C:\\InjectedMachine', + 'C:\\InjectedUser' + ]) + expect(readPersistedWindowsPathSegments()).toEqual([]) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -173,6 +333,33 @@ describe('readPersistedWindowsPathSegments', () => { } }) + it('does not resurrect a successfully emptied hive when the other hive fails', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + invalidatePersistedWindowsPathCache() + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine']) + invalidatePersistedWindowsPathCache() + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine']) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -232,6 +419,71 @@ describe('readPersistedWindowsPathSegments', () => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + + it('does not let an older async read replace a newer synchronous refresh', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + const olderRead = readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + + await expect(olderRead).resolves.toEqual(['C:\\NewMachine', 'C:\\NewUser']) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('uses a current-generation refresh before merging an invalidated read', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockImplementationOnce(() => { + queueMicrotask(invalidatePersistedWindowsPathCache) + return registryPath('C:\\OldUser') + }) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const env = { Path: 'C:\\Injected' } + await mergePersistedWindowsPathAsync(env) + + expect(env.Path).toBe('C:\\Injected;C:\\NewMachine;C:\\NewUser') + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + invalidatePersistedWindowsPathCache() + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(6) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) describe('mergePersistedWindowsPath', () => { @@ -247,7 +499,7 @@ describe('mergePersistedWindowsPath', () => { expect(env.Path).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows') }) - it('appends missing persisted segments without reordering the inherited PATH', () => { + it('adopts persisted machine and user PATH ordering', () => { const execFileSync = vi .fn() .mockReturnValueOnce( @@ -271,7 +523,7 @@ describe('mergePersistedWindowsPath', () => { mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) expect(env.Path).toBe( - 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\Orca\\bin' + 'C:\\Windows\\System32;C:\\Existing;C:\\Users\\me\\AppData\\Local\\Orca\\bin' ) }) @@ -318,6 +570,73 @@ describe('mergePersistedWindowsPath', () => { expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' }) }) + + it('stops an inherited WindowsApps alias from shadowing a newly installed Python', () => { + const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' + const scripts = `${python}Scripts\\` + const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32;C:\\Windows\r\n') + .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${scripts};${windowsApps}\r\n`) + const env = { Path: `C:\\Windows\\system32;C:\\Windows;${windowsApps}` } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + const segments = env.Path.split(';') + expect(segments.indexOf(python)).toBeLessThan(segments.indexOf(windowsApps)) + expect(env.Path).toBe(`C:\\Windows\\system32;C:\\Windows;${python};${scripts};${windowsApps}`) + }) + + it('keeps injected entries ahead of the persisted PATH', () => { + const injected = 'C:\\Users\\me\\AppData\\Local\\Orca\\resources\\bin' + const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' + const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32\r\n') + .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${windowsApps}\r\n`) + const env = { Path: `${injected};C:\\Windows\\system32;${windowsApps}` } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe(`${injected};C:\\Windows\\system32;${python};${windowsApps}`) + }) + + it('leaves the inherited PATH untouched when both registry reads are blocked', () => { + const execFileSync = vi.fn().mockImplementation(() => { + throw new Error('ERROR: Access is denied.') + }) + const env = { Path: 'C:\\Windows\\system32;C:\\Existing' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe('C:\\Windows\\system32;C:\\Existing') + }) + + it('deduplicates segments that differ only by trailing separators', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Tools\r\n') + .mockReturnValueOnce(' Path REG_SZ \r\n') + const env = { Path: 'C:\\Tools\\' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path.split(';')).toEqual(['C:\\Tools']) + }) + + it('canonicalizes drive-root slash style and repetition for comparison', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\\\\r\n') + .mockReturnValueOnce(' Path REG_SZ \r\n') + const env = { Path: 'C:/' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path.split(';')).toEqual(['C:\\\\']) + }) }) describe('resolvePathEnvKey', () => { diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 8fad453cab5..554e8768612 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,11 +1,15 @@ import type { execFile, execFileSync } from 'node:child_process' -import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' +import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { mergeWindowsPathSegments } from './windows-path-segment-merge' +import { + WindowsPathRegistryFallback, + type RegistryPathRead +} from './windows-path-registry-fallback' import { readWindowsPathRegistry } from './windows-path-registry-reader' +export { resolvePathEnvKey } from './windows-path-segment-merge' + type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -17,8 +21,6 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { failed: boolean; segments: string[] } - const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], ['HKCU\\Environment', 'Path'] @@ -26,6 +28,9 @@ const WINDOWS_PATH_REGISTRY_KEYS = [ const PERSISTED_WINDOWS_PATH_CACHE_TTL_MS = 30_000 const PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS = 5_000 +const windowsPathRegistryFallback = new WindowsPathRegistryFallback( + WINDOWS_PATH_REGISTRY_KEYS.length +) let persistedWindowsPathCache: | { @@ -34,6 +39,9 @@ let persistedWindowsPathCache: } | undefined let pendingPersistedWindowsPathRefresh: Promise | undefined +let persistedWindowsPathCacheGeneration = 0 +let persistedWindowsPathReadSequence = 0 +let persistedWindowsPathCommittedSequence = 0 function parseRegistryPathValue(output: string, valueName: string): string | null { const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') @@ -82,19 +90,20 @@ function readNativeRegistryPaths( })) } -function keepLastGoodSegments(segments: string[], failedReads: number): string[] { - if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { - return [...persistedWindowsPathCache.segments] +function cachePersistedWindowsPathReads(reads: RegistryPathRead[], readSequence: number): string[] { + if (readSequence < persistedWindowsPathCommittedSequence) { + // Why: a slower async read must not replace a newer synchronous refresh. + return persistedWindowsPathCache ? [...persistedWindowsPathCache.segments] : [] } - return segments -} - -function cachePersistedWindowsPathSegments(segments: string[], failedReads: number): string[] { - // Why: timeouts or policy blocks must not replace a usable cache, while successful empty - // registry values still need to remove stale entries. - const kept = keepLastGoodSegments(segments, failedReads) - persistedWindowsPathCache = { readAt: Date.now(), segments: [...kept] } - return [...kept] + persistedWindowsPathCommittedSequence = readSequence + const segments = windowsPathRegistryFallback.commitReads(reads) + if (!segments) { + // Why: unresolved hives preserve ordering, but still need negative caching on the PTY hot path. + persistedWindowsPathCache = { readAt: Date.now(), segments: [] } + return [] + } + persistedWindowsPathCache = { readAt: Date.now(), segments: [...segments] } + return [...segments] } function readRegistryPathAsync( @@ -145,19 +154,15 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - if ( - !options.forceRefresh && - useProductionCache && - pendingPersistedWindowsPathRefresh && - persistedWindowsPathCache - ) { + if (!options.forceRefresh && useProductionCache && pendingPersistedWindowsPathRefresh) { // Why: synchronous PTY construction cannot await the active refresh; its stale cache is // safer than duplicating the registry read on Electron's main thread. - return [...persistedWindowsPathCache.segments] + return persistedWindowsPathCache ? [...persistedWindowsPathCache.segments] : [] } const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) + const readSequence = useProductionCache ? ++persistedWindowsPathReadSequence : 0 const reads = options.execFileSync ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { try { @@ -180,8 +185,6 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions }) : readNativeRegistryPaths(env, pathDelimiter) const segments = reads.flatMap((read) => read.segments) - const failedReads = reads.filter((read) => read.failed).length - if (!useProductionCache) { return segments } @@ -189,7 +192,7 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still // picking up newly installed CLIs soon. - return cachePersistedWindowsPathSegments(segments, failedReads) + return cachePersistedWindowsPathReads(reads, readSequence) } export async function readPersistedWindowsPathSegmentsAsync( @@ -220,6 +223,8 @@ export async function readPersistedWindowsPathSegmentsAsync( const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) + const cacheGeneration = persistedWindowsPathCacheGeneration + const readSequence = useProductionCache ? ++persistedWindowsPathReadSequence : 0 const refresh = ( options.execFile ? Promise.all( @@ -239,7 +244,11 @@ export async function readPersistedWindowsPathSegmentsAsync( if (!useProductionCache) { return segments } - return cachePersistedWindowsPathSegments(segments, reads.filter((read) => read.failed).length) + if (cacheGeneration !== persistedWindowsPathCacheGeneration) { + // Why: callers must not merge or inspect a snapshot invalidated while its queries ran. + return readPersistedWindowsPathSegmentsAsync() + } + return cachePersistedWindowsPathReads(reads, readSequence) }) if (!useProductionCache) { @@ -256,63 +265,18 @@ export async function readPersistedWindowsPathSegmentsAsync( } export function __resetPersistedWindowsPathCacheForTests(): void { + invalidatePersistedWindowsPathCache() + windowsPathRegistryFallback.reset() + persistedWindowsPathReadSequence = 0 + persistedWindowsPathCommittedSequence = 0 +} + +export function invalidatePersistedWindowsPathCache(): void { + persistedWindowsPathCacheGeneration += 1 persistedWindowsPathCache = undefined pendingPersistedWindowsPathRefresh = undefined } -/** Resolves the first PATH key a Windows child reads from an env block. */ -export function resolvePathEnvKey( - env: NodeJS.ProcessEnv | Record, - platform: NodeJS.Platform, - hostEnv: NodeJS.ProcessEnv = process.env -): string { - if (platform !== 'win32') { - return 'PATH' - } - // Why: the daemon receives a sparse env patch and re-merges its own block underneath it; - // matching the block's spelling stops that merge from resurrecting the other one. - return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' -} - -// Why: Win32 resolves the first case-insensitive key, and object order preserves block order. -function firstWindowsPathEnvKey( - env: NodeJS.ProcessEnv | Record -): string | undefined { - for (const key of Object.keys(env)) { - if (key.toLowerCase() === 'path' && env[key] !== undefined) { - return key - } - } - return undefined -} - -function mergeWindowsPathSegments( - env: NodeJS.ProcessEnv, - persistedSegments: string[], - platform: NodeJS.Platform, - sourceEnv: NodeJS.ProcessEnv -): void { - expandWindowsPathEnvironmentVariables(env, platform) - const pathKey = resolvePathEnvKey(env, platform, sourceEnv) - const pathDelimiter = getPathDelimiter(platform) - const currentPath = - env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) - const currentSegments = splitPathSegments(currentPath, pathDelimiter) - const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) - const missing = persistedSegments.filter((segment) => { - const normalized = segment.toLowerCase() - if (existing.has(normalized)) { - return false - } - existing.add(normalized) - return true - }) - - if (missing.length > 0) { - env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) - } -} - export function mergePersistedWindowsPath( env: NodeJS.ProcessEnv, options: ReadWindowsPathOptions = {} @@ -323,9 +287,7 @@ export function mergePersistedWindowsPath( } const sourceEnv = options.env ?? process.env - // Why: Windows broadcasts PATH changes to future processes, but a running - // Electron app keeps its old environment. Append the persisted additions so - // newly installed CLIs resolve without unexpectedly reordering existing PATH. + // Why: append-only merging lets stale entries shadow newly installed executables. mergeWindowsPathSegments(env, readPersistedWindowsPathSegments(options), platform, sourceEnv) } diff --git a/src/main/pty/windows-path-registry-change.test.ts b/src/main/pty/windows-path-registry-change.test.ts new file mode 100644 index 00000000000..e6baee2cadf --- /dev/null +++ b/src/main/pty/windows-path-registry-change.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { + installWindowsPathRegistryChangeListener, + WINDOWS_SETTING_CHANGE_MESSAGE +} from './windows-path-registry-change' + +describe('installWindowsPathRegistryChangeListener', () => { + it('invalidates the persisted PATH cache on a Windows settings change', () => { + const hooks = new Map void>() + const invalidate = vi.fn() + installWindowsPathRegistryChangeListener( + { + hookWindowMessage: (message, callback) => { + hooks.set(message, callback) + } + }, + { invalidate, platform: 'win32' } + ) + + hooks.get(WINDOWS_SETTING_CHANGE_MESSAGE)?.(Buffer.alloc(0), Buffer.alloc(0)) + + expect(invalidate).toHaveBeenCalledOnce() + }) + + it('does not hook native messages outside Windows', () => { + const hookWindowMessage = vi.fn() + + installWindowsPathRegistryChangeListener({ hookWindowMessage }, { platform: 'linux' }) + + expect(hookWindowMessage).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-path-registry-change.ts b/src/main/pty/windows-path-registry-change.ts new file mode 100644 index 00000000000..604d06de713 --- /dev/null +++ b/src/main/pty/windows-path-registry-change.ts @@ -0,0 +1,19 @@ +import type { BrowserWindow } from 'electron' +import { invalidatePersistedWindowsPathCache } from './windows-environment-path' + +export const WINDOWS_SETTING_CHANGE_MESSAGE = 0x001a + +type WindowsMessageWindow = Pick + +export function installWindowsPathRegistryChangeListener( + window: Partial, + options: { invalidate?: () => void; platform?: NodeJS.Platform } = {} +): void { + if ((options.platform ?? process.platform) !== 'win32' || !window.hookWindowMessage) { + return + } + window.hookWindowMessage( + WINDOWS_SETTING_CHANGE_MESSAGE, + options.invalidate ?? invalidatePersistedWindowsPathCache + ) +} diff --git a/src/main/pty/windows-path-registry-fallback.test.ts b/src/main/pty/windows-path-registry-fallback.test.ts new file mode 100644 index 00000000000..a7db74af310 --- /dev/null +++ b/src/main/pty/windows-path-registry-fallback.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { WindowsPathRegistryFallback } from './windows-path-registry-fallback' + +const success = (segments: string[]) => ({ failed: false, segments }) +const failure = () => ({ failed: true, segments: [] }) + +describe('WindowsPathRegistryFallback', () => { + it('withholds an incomplete snapshot until every source has succeeded', () => { + const fallback = new WindowsPathRegistryFallback(2) + + expect(fallback.commitReads([success(['C:\\Machine']), failure()])).toBeUndefined() + expect(fallback.commitReads([failure(), success(['C:\\User'])])).toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + }) + + it('uses each source independently when a later read fails', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\OldMachine']), success(['C:\\OldUser'])]) + + expect(fallback.commitReads([success(['C:\\NewMachine']), failure()])).toEqual([ + 'C:\\NewMachine', + 'C:\\OldUser' + ]) + }) + + it('treats a successful empty source as authoritative', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\Machine']), success(['C:\\User'])]) + + expect(fallback.commitReads([failure(), success([])])).toEqual(['C:\\Machine']) + expect(fallback.commitReads([failure(), failure()])).toEqual(['C:\\Machine']) + }) + + it('clears retained sources on reset', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\Machine']), success(['C:\\User'])]) + + fallback.reset() + + expect(fallback.commitReads([failure(), failure()])).toBeUndefined() + }) + + it('does not expose retained arrays to callers', () => { + const fallback = new WindowsPathRegistryFallback(2) + const machine = ['C:\\Machine'] + const first = fallback.commitReads([success(machine), success(['C:\\User'])])! + + machine[0] = 'C:\\MutatedInput' + first[0] = 'C:\\MutatedOutput' + + expect(fallback.commitReads([failure(), failure()])).toEqual(['C:\\Machine', 'C:\\User']) + }) +}) diff --git a/src/main/pty/windows-path-registry-fallback.ts b/src/main/pty/windows-path-registry-fallback.ts new file mode 100644 index 00000000000..c551144119c --- /dev/null +++ b/src/main/pty/windows-path-registry-fallback.ts @@ -0,0 +1,33 @@ +export type RegistryPathRead = { failed: boolean; segments: string[] } + +export class WindowsPathRegistryFallback { + private readonly lastGoodBySource: (string[] | undefined)[] + + constructor(sourceCount: number) { + this.lastGoodBySource = Array.from({ length: sourceCount }) + } + + commitReads(reads: RegistryPathRead[]): string[] | undefined { + if (reads.length !== this.lastGoodBySource.length) { + return undefined + } + + const resolved = reads.map((read, index) => { + if (!read.failed) { + const segments = [...read.segments] + this.lastGoodBySource[index] = segments + return segments + } + const lastGood = this.lastGoodBySource[index] + return lastGood ? [...lastGood] : undefined + }) + + return resolved.some((segments) => segments === undefined) + ? undefined + : resolved.flatMap((segments) => segments ?? []) + } + + reset(): void { + this.lastGoodBySource.fill(undefined) + } +} diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts index 634cd30e72a..387e4238b8e 100644 --- a/src/main/pty/windows-path-registry-reader.test.ts +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -45,12 +45,24 @@ describe('readWindowsPathRegistry', () => { ]) }) - it('treats a missing PATH value as a failed query', () => { + it('treats a missing PATH value in a readable hive as empty', () => { __setWindowsPathRegistryLoaderForTests(() => ({ HK: { LM: 1, CU: 2 }, getRegistryKey: vi.fn(() => ({})) })) + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: false, value: '' } + ]) + }) + + it('treats a present but unreadable PATH entry as a failed read', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({ Path: undefined })) + })) + expect(readWindowsPathRegistry()).toEqual([ { failed: true, value: null }, { failed: true, value: null } diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts index 6f98c45e390..cff14c6ff3e 100644 --- a/src/main/pty/windows-path-registry-reader.ts +++ b/src/main/pty/windows-path-registry-reader.ts @@ -26,13 +26,15 @@ function readRegistryPath( if (!values || typeof values !== 'object') { return { failed: true, value: null } } - const entry = Object.entries(values).find( + const matchingEntry = Object.entries(values).find( ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() - )?.[1] - if (!entry) { - return { failed: true, value: null } + ) + if (!matchingEntry) { + return { failed: false, value: '' } } + const entry = matchingEntry[1] if ( + !entry || (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || typeof entry.value !== 'string' ) { diff --git a/src/main/pty/windows-path-segment-merge.ts b/src/main/pty/windows-path-segment-merge.ts new file mode 100644 index 00000000000..790a6cd06c9 --- /dev/null +++ b/src/main/pty/windows-path-segment-merge.ts @@ -0,0 +1,83 @@ +import { + expandWindowsEnvironmentVariables, + expandWindowsPathEnvironmentVariables +} from '../../shared/windows-environment-expansion' + +export function resolvePathEnvKey( + env: NodeJS.ProcessEnv | Record, + platform: NodeJS.Platform, + hostEnv: NodeJS.ProcessEnv = process.env +): string { + if (platform !== 'win32') { + return 'PATH' + } + // Why: match the daemon's env-block spelling so its merge cannot resurrect another PATH key. + return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' +} + +// Why: Win32 uses the first case-insensitive key, and object order preserves block order. +function firstWindowsPathEnvKey( + env: NodeJS.ProcessEnv | Record +): string | undefined { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path' && env[key] !== undefined) { + return key + } + } + return undefined +} + +function normalizeSegmentKey(segment: string): string { + const trimmed = segment.replace(/[\\/]+$/, '') + // Why: roots keep one separator because `C:\` and drive-relative `C:` differ. + return (/^[a-z]:$/i.test(trimmed) && trimmed !== segment ? `${trimmed}\\` : trimmed).toLowerCase() +} + +function dedupeSegments(segments: string[]): string[] { + const seen = new Set() + return segments.filter((segment) => { + const key = normalizeSegmentKey(segment) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { + return pathValue + .split(pathDelimiter) + .map((segment) => segment.trim()) + .filter(Boolean) +} + +export function mergeWindowsPathSegments( + env: NodeJS.ProcessEnv, + persistedSegments: string[], + platform: NodeJS.Platform, + sourceEnv: NodeJS.ProcessEnv +): void { + expandWindowsPathEnvironmentVariables(env, platform) + const pathKey = resolvePathEnvKey(env, platform, sourceEnv) + const pathDelimiter = platform === 'win32' ? ';' : ':' + const currentPath = + env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + + if (persistedSegments.length === 0) { + return + } + + const persisted = dedupeSegments(persistedSegments) + const persistedKeys = new Set(persisted.map(normalizeSegmentKey)) + // Why: keep launch-time and Orca-injected entries that have no persisted position. + const injected = dedupeSegments( + currentSegments.filter((segment) => !persistedKeys.has(normalizeSegmentKey(segment))) + ) + const merged = [...injected, ...persisted].join(pathDelimiter) + + if (merged !== currentPath) { + env[pathKey] = merged + } +} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index e46d84855f3..d34ef495965 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -59,6 +59,7 @@ import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -304,6 +305,7 @@ export function createMainWindow( } }) const rendererWebContentsId = mainWindow.webContents.id + installWindowsPathRegistryChangeListener(mainWindow) // Why: native paste fallback is privileged IPC; only the top-level renderer may request it. setTrustedUIRendererWebContentsId(rendererWebContentsId) From e77e1fe850108aaa2d03674745f8a1b7d3895637 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:16:24 -0700 Subject: [PATCH 002/109] fix(claude): guard cold-restore resume selectors (#13868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): guard cold-restore resume selectors Persisted Claude default args or a custom command can carry their own --resume/-r/--continue/-c selectors (a bare picker default or a stale id). Cold restore appended the authoritative --resume after them, typing a command with competing selectors into the restored pane (#12982). buildAgentResumeStartupPlan now routes Claude through a selector guard that tokenizes the base with the existing startup tokenizer, strips selectors in option position only (value-taking options keep dash-leading values), and appends exactly one authoritative selector, inserting before Claude's own -- terminator when present. Splicing is span-based so untouched bytes stay verbatim, wrapper commands are left alone, and any tokenization failure falls back to the previous append-only behavior. Launch paths, other agents, persistence, and the wire are unchanged. * fix(claude): harden resume selector guard against false matches Round-1 review findings: locate the claude executable by command position (index 0, after a wrapper --, or behind NAME=value assignments) so an argument merely ending in /claude can never be mistaken for it; stop matching the joined -r form, which was ambiguous with dash-leading option values and forced an unmaintainable arity table (now deleted). Ambiguous shapes degrade to the pre-guard append-only behavior. * fix(claude): fail resume guard open on chained shell syntax Round-2 review findings: an unquoted operator or newline after the claude token means the base chains other commands, and splicing across that boundary handed the selector to the wrong command — detect it and fall back to plain appending. Also recognize claude behind PowerShell's & call operator, decouple the test oracle from the implementation's selector predicate, add Windows tokenizer span tests, and rename the module after its public API. * fix(claude): flag bare shell operators inside the tokenizers Round-3 review findings: the guard's operator scan compared raw source to token value, so one quote or escape anywhere in a token hid a shell-active operator outside the quotes and the splice crossed a live command boundary, losing the resume entirely. Both tokenizers now flag tokens carrying an unquoted, unescaped operator byte (or a word-leading # comment on posix/powershell) on their spans, where quote state actually lives, and the guard fails open on that flag. Also strengthens the redirect fail-open test to carry a stale selector, re-tokenizes each raw span in the shell span tests, and documents agent-resume-argv-drop as codex-only. * fix(claude): flag expansions and clamp separator backoff Round-4 review findings: unquoted multi-token expansions (backtick, $(, ${) split across whitespace, so removing only the recognized selector token left a broken construct tail — both tokenizers now raise the span flag (renamed bareShellSyntax) for those openers, on cmd also for operators between single quotes, which cmd does not treat as quoting. The separator backoff is clamped to the previous token's span end so a token ending in an escaped space can no longer donate its escape to the appended selector. * fix(claude): treat cmd single-quoted regions as unmodelable Round-5 review finding: cmd.exe has no single-quote syntax, so the Windows tokenizer's grouping of a single-quoted region diverges from what cmd parses — literal argv like 'claude ...--resume... old' was being read as a real selector and stripped, and a literal '--' as claude's terminator. Flag any cmd single-quoted token as bareShellSyntax so the guard fails open. * fix(claude): flag quoted expansions and scope assignment prefixes Round-6 review findings: the span flag was only evaluated in the unquoted branch, so an expansion opener inside double quotes went unflagged — and inside $(…)/backticks a nested quote re-opens a context this tokenizer does not model, so the splice could cut mid-construct (syntax error, or a silently mutated substitution body). Both tokenizers now flag those, and the flag is renamed divergesFromShell to say what it means. Restrict the NAME=value command-position prefix to posix, where that syntax exists. Drops two branches proven dead. * fix(claude): model shell-literal escapes and scan the whole base Round-7 review findings: (1) the divergence scan started after the claude token, so an expansion opened in a prefix — $(x; npx -- claude --resume s) — had its closer spliced away, producing a base bash cannot parse; it now covers every token including the executable, exempting only PowerShell's leading call operator. (2) posix drops a double-quoted backslash the shell keeps literal, and the Windows escape branch ran inside quoted regions where cmd/PowerShell keep the escape byte literal — both now flagged, so a literal can never be misread as a selector. (3) an unquoted line continuation hid a selector inside a token and skipped the newline gap check. Also removes a third provably dead branch and collapses the cut floor into the cut itself. * fix(claude): flag escapes the tokenizer models but the shell removes Round-8 review findings, all one family — escapes whose token value hides a selector the shell would see: a double-quoted line continuation (bash deletes both bytes), posix $'…'/$"…" quoting, a windows escaped newline, and a trailing unpaired escape. The last one was previously written off as pre-fix-identical, but once stripping happens the dangling escape swallows the separator and no exact --resume reaches claude at all — strictly worse than appending, so it must fail open. Also folds the three gap predicates into one scan. * fix(claude): stop over-flagging a literal dollar sign Round-9 review findings from both lanes: inside double quotes only $( and ${ open an expansion — $' and $" are literal there — and a trailing $ was flagged unconditionally because JS ''.includes('') is true. Both made the guard fail open on modelable bases, leaving the stale selector to compete, so #12982 went unfixed for them. Separately, cmd strips ^ before the child re-splits on the bare whitespace, so an escaped separator hides two real arguments and must fail open rather than drop one. * fix(claude): fail open on cmd caret-quotes and bare PowerShell syntax Round-10 review findings, both Windows-only (a bash oracle cannot reach them): cmd strips a caret before a quote and the child's parser then reads a bare quote delimiter, so the tokenizer's word boundaries stop matching argv — one case turned a working resume into no resume at all, another let a stale selector survive the splice. And bare (…)/{…} are live PowerShell syntax in argument position, so splicing through them emitted unbalanced output that PowerShell cannot parse. * fix(claude): fail open on the PowerShell stop-parsing token Round-11 review finding: after a bare --%, PowerShell passes the rest of the line to the child literally, so the guard stripped a real selector and then appended quoting that arrives as literal bytes — claude ends up with no exact --resume at all, worse than leaving the stale one. Quoted "--%" and cmd, where the token is ordinary, still splice. * fix(claude): model cmd backslash-escaped quotes Round-11 review finding: an odd run of backslashes before a quote makes it a literal byte to the child's CommandLineToArgvW parser, not a delimiter, so the tokenizer's word boundaries stopped matching argv. Orca manufactures that pattern itself — quoteStartupArg wraps every token in quotes without escaping a trailing backslash — so a pasted Windows path was enough to move the selector into a desynced region and leave claude with no resume flag. Also replaces a caret test case that was byte-identical before and after its own fix, and merges two stacked comment blocks. * fix(claude): fail open on PowerShell double-quoted escape sequences Round-12 finding: PowerShell expands backtick escapes only inside double quotes, so a sequence there produces a token value argv never sees — the guard could strip "-`r" plus the argument after it. Also narrows the stop-parsing comment: a quoted --% can engage stop-parsing before a parameter token, where the base is already mangled either way. * fix(claude): flag PowerShell escape sequences in bare arguments too Round-13 finding: the previous commit gated on quote === '"', but PowerShell's tokenizer calls Backtick() from ScanGenericToken, so it expands these sequences in unquoted arguments as well — bare -`r really is a control character, not -r. The guard read it as a selector and dropped it plus the argument after it. Widening to all PowerShell contexts measures 0 under-flag and 0 over-flag across the full printable matrix; the backtick-escaped-space idiom still splices. Also swaps a test case that was byte-identical with and without its own fix. * fix(claude): drop a token-leading PowerShell backtick before whitespace Round-14 observations, all pre-existing and measured: PowerShell drops a token-leading backtick together with the whitespace after it, emitting no token, so the tokenizer's extra token shifted the locator; and a backtick before a bare CR is a line continuation too. Flagging both takes the lane's 329k-base sweep from 87 bad to 0 with no new failures and the must-splice list byte-unchanged. Also corrects a comment that no longer listed every PowerShell divergence. * docs(claude): correct the bare-CR rationale in the tokenizer comment Round-15 verified against a real PowerShell 7.6.4 engine: a backtick before a bare CR is not a line continuation there — pwsh keeps the CR in the token. The flag stays because 5.1 is unverified and failing open costs nothing, but the comment now says that rather than claiming continuation. --- src/shared/agent-resume-argv-drop.ts | 3 + .../agent-resume-launch-command.test.ts | 580 ++++++++++++++++++ src/shared/agent-resume-launch-command.ts | 168 +++++ src/shared/commit-message-prompt.test.ts | 52 +- src/shared/commit-message-prompt.ts | 44 +- src/shared/tui-agent-launch-command.ts | 2 +- src/shared/tui-agent-startup-shell.test.ts | 58 ++ src/shared/tui-agent-startup-shell.ts | 77 ++- src/shared/tui-agent-startup.ts | 8 +- 9 files changed, 974 insertions(+), 18 deletions(-) create mode 100644 src/shared/agent-resume-launch-command.test.ts create mode 100644 src/shared/agent-resume-launch-command.ts create mode 100644 src/shared/tui-agent-startup-shell.test.ts diff --git a/src/shared/agent-resume-argv-drop.ts b/src/shared/agent-resume-argv-drop.ts index ae393d0897e..df8c16aae4f 100644 --- a/src/shared/agent-resume-argv-drop.ts +++ b/src/shared/agent-resume-argv-drop.ts @@ -37,6 +37,9 @@ function stripSuffix(command: string, suffix: string): string | null { * Remove the resume argv `buildAgentResumeStartupPlan` appended, leaving the plain * agent launch. Main uses this when it cannot verify which account owns the session, * so the pane starts fresh instead of resuming under whichever account is selected. + * Codex-only in practice: claude resume commands may carry the selector before a + * `--` terminator (agent-resume-launch-command.ts), which this trailing-suffix + * strip would report as `unrecognized`. */ export function dropAgentResumeArgvFromCommand(args: { command: string diff --git a/src/shared/agent-resume-launch-command.test.ts b/src/shared/agent-resume-launch-command.test.ts new file mode 100644 index 00000000000..5bf4797472b --- /dev/null +++ b/src/shared/agent-resume-launch-command.test.ts @@ -0,0 +1,580 @@ +import { describe, expect, it } from 'vitest' +import { buildClaudeResumeLaunchCommand } from './agent-resume-launch-command' +import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' + +const SESSION_ID = 'claude-session-1' +const RESUME = ['--resume', SESSION_ID] as const +const providerSession = { key: 'session_id', id: SESSION_ID } as const + +const SHELLS: { platform: NodeJS.Platform; shell: AgentStartupShell }[] = [ + { platform: 'linux', shell: 'posix' }, + { platform: 'darwin', shell: 'posix' }, + { platform: 'win32', shell: 'powershell' }, + { platform: 'win32', shell: 'cmd' } +] + +/** Independent selector oracle — deliberately NOT the implementation's own + * predicate, so a regression that shrinks the stripped set cannot also blind + * this assertion. */ +function isSelectorShapedToken(token: string): boolean { + return ( + ['--resume', '--continue', '-r', '-c'].includes(token) || + ['--resume=', '--continue=', '-r=', '-c='].some((prefix) => token.startsWith(prefix)) + ) +} + +/** Tokenizes a launch command and asserts exactly one identity-bearing resume. */ +function expectSingleAuthoritativeResume(command: string, shell: AgentStartupShell): void { + const tokenized = tokenizeStartupCommand(command, shell) + expect(tokenized.ok).toBe(true) + if (!tokenized.ok) { + return + } + const selectors = tokenized.tokens.filter(isSelectorShapedToken) + expect(selectors).toEqual(['--resume']) + const index = tokenized.tokens.indexOf('--resume') + expect(tokenized.tokens[index + 1]).toBe(SESSION_ID) +} + +describe('buildClaudeResumeLaunchCommand', () => { + it.each(SHELLS)('appends the authoritative selector to a plain base ($shell)', ({ shell }) => { + const command = buildClaudeResumeLaunchCommand('claude', RESUME, shell) + expectSingleAuthoritativeResume(command, shell) + expect(command.startsWith('claude ')).toBe(true) + }) + + it.each(SHELLS)('strips a bare persisted --resume picker default ($shell)', ({ shell }) => { + const base = shell === 'cmd' ? 'claude "--resume"' : "claude '--resume'" + const command = buildClaudeResumeLaunchCommand(base, RESUME, shell) + expectSingleAuthoritativeResume(command, shell) + }) + + it.each([ + 'claude --resume stale-session', + 'claude --resume=stale-session', + 'claude -r stale-session', + 'claude -r=stale-session', + 'claude --resume= --model sonnet', + 'claude --continue', + 'claude -c', + 'claude --continue=1', + 'claude -c=1', + 'claude --resume stale -r older --continue -c' + ])('replaces stale selectors in %s', (base) => { + const command = buildClaudeResumeLaunchCommand(base, RESUME, 'posix') + expectSingleAuthoritativeResume(command, 'posix') + }) + + it('keeps surviving options when stripping selectors', () => { + expect( + buildClaudeResumeLaunchCommand('claude --resume stale --model sonnet', RESUME, 'posix') + ).toBe(`claude --model sonnet '--resume' '${SESSION_ID}'`) + }) + + it('never mistakes a dash-leading option value for a selector', () => { + expect(buildClaudeResumeLaunchCommand('claude --model -recent', RESUME, 'posix')).toBe( + `claude --model -recent '--resume' '${SESSION_ID}'` + ) + expect( + buildClaudeResumeLaunchCommand('claude --append-system-prompt -rules-here', RESUME, 'posix') + ).toBe(`claude --append-system-prompt -rules-here '--resume' '${SESSION_ID}'`) + expect(buildClaudeResumeLaunchCommand('claude --agent -reviewer', RESUME, 'posix')).toBe( + `claude --agent -reviewer '--resume' '${SESSION_ID}'` + ) + expect(buildClaudeResumeLaunchCommand('claude --plugin-url -remote.zip', RESUME, 'posix')).toBe( + `claude --plugin-url -remote.zip '--resume' '${SESSION_ID}'` + ) + }) + + it('leaves the ambiguous joined -r form alone (degrades to pre-guard behavior)', () => { + expect(buildClaudeResumeLaunchCommand('claude -rstale-session', RESUME, 'posix')).toBe( + `claude -rstale-session '--resume' '${SESSION_ID}'` + ) + }) + + it('leaves wrapper commands untouched and appends at the end', () => { + expect(buildClaudeResumeLaunchCommand('bash -c claude', RESUME, 'posix')).toBe( + `bash -c claude '--resume' '${SESSION_ID}'` + ) + expect(buildClaudeResumeLaunchCommand('mise exec -- claude', RESUME, 'posix')).toBe( + `mise exec -- claude '--resume' '${SESSION_ID}'` + ) + expect(buildClaudeResumeLaunchCommand('sudo -u dev -- claude', RESUME, 'posix')).toBe( + `sudo -u dev -- claude '--resume' '${SESSION_ID}'` + ) + }) + + it('strips selectors that follow claude after a wrapper terminator', () => { + expect( + buildClaudeResumeLaunchCommand("mise exec -- claude '--resume' stale", RESUME, 'posix') + ).toBe(`mise exec -- claude '--resume' '${SESSION_ID}'`) + }) + + it('fails open for claude outside command position (wrapper without --)', () => { + // Why: npx/bunx-style passthrough cannot be told apart from an argument + // that merely names claude, so the guard defers to append-only behavior. + expect(buildClaudeResumeLaunchCommand("npx claude '--resume'", RESUME, 'posix')).toBe( + `npx claude '--resume' '--resume' '${SESSION_ID}'` + ) + }) + + it('never mistakes a claude-suffixed argument for the executable', () => { + expect( + buildClaudeResumeLaunchCommand( + 'ssh -i ~/.ssh/claude devbox -- claude --resume OLD', + RESUME, + 'posix' + ) + ).toBe(`ssh -i ~/.ssh/claude devbox -- claude '--resume' '${SESSION_ID}'`) + expect( + buildClaudeResumeLaunchCommand( + 'mise exec --cd /Users/me/src/claude -- claude --resume OLD', + RESUME, + 'posix' + ) + ).toBe(`mise exec --cd /Users/me/src/claude -- claude '--resume' '${SESSION_ID}'`) + // No claude in command position at all: wrapper flags stay untouched. + expect( + buildClaudeResumeLaunchCommand( + 'nix develop /Users/me/src/claude -c claude --resume OLD', + RESUME, + 'posix' + ) + ).toBe(`nix develop /Users/me/src/claude -c claude --resume OLD '--resume' '${SESSION_ID}'`) + }) + + it.each(['claude "\\--resume" old', 'claude "\\-r" old', 'claude --model "\\--resume"'])( + 'fails open on a double-quoted backslash the shell keeps literal: %s', + (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + } + ) + + it('still strips when a double-quoted backslash is shell-consumed', () => { + expect( + buildClaudeResumeLaunchCommand('claude --model "a\\"b" --resume old', RESUME, 'posix') + ).toBe(`claude --model "a\\"b" '--resume' '${SESSION_ID}'`) + }) + + it.each([ + 'claude "--resu\\\nme" --resume stale', + "claude $'-c' --resume stale", + "claude $'--resu\\x6de' --resume stale" + ])('fails open when an escape hides a selector from the tokenizer: %s', (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it.each([ + 'claude --resume old --append-system-prompt "spend under 5$"', + 'claude --resume old --note x$', + 'claude --resume old --note "a$"' + ])('still strips when a $ is not an expansion opener: %s', (base) => { + const command = buildClaudeResumeLaunchCommand(base, RESUME, 'posix') + expect(command).not.toContain('--resume old') + expectSingleAuthoritativeResume(command, 'posix') + }) + + it.each(['claude a^" --resume ^"b', 'claude ^"x --resume^" --resume old'])( + 'fails open when a cmd caret escapes a quote: %s', + (base) => { + // cmd strips the caret and the child's parser reads a bare quote + // delimiter, so the tokenizer's word boundaries stop matching argv. + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe( + `${base} "--resume" "${SESSION_ID}"` + ) + } + ) + + it.each(['claude --resume (Get-Content id.txt)', 'claude --hook { npm -c run } --resume old'])( + 'fails open on bare powershell evaluation syntax: %s', + (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + } + ) + + it.each([ + 'claude "-`r" foo --model x', + 'claude "-`u{72}" oldid --resume x', + 'claude -`r foo --model x', + 'claude --resum`e stale --model x' + ])('fails open on powershell escape sequences, quoted or bare: %s', (base) => { + // PowerShell expands these in bare arguments too, so the tokenizer's + // token value is not what argv receives. + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on a token-leading powershell backtick before whitespace', () => { + // PowerShell drops the backtick AND the whitespace, emitting no token, + // so the tokenizer's extra token would shift the locator. + const base = 'claude --resume ` \t"q"' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on the powershell stop-parsing token', () => { + // After a bare --%, PowerShell hands the rest of the line to the child + // literally, so an appended quoted selector would arrive as literal bytes. + const base = 'claude --% --resume stale --model sonnet' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('still strips when --% is quoted or on cmd, where it is an ordinary token', () => { + expect( + buildClaudeResumeLaunchCommand('claude "--%" --resume stale', RESUME, 'powershell') + ).toBe(`claude "--%" '--resume' '${SESSION_ID}'`) + expect(buildClaudeResumeLaunchCommand('claude --% --resume stale', RESUME, 'cmd')).toBe( + `claude --% "--resume" "${SESSION_ID}"` + ) + }) + + it('still strips with parens safely inside quotes on powershell', () => { + expect( + buildClaudeResumeLaunchCommand( + 'claude --allowedTools "Bash(git:*)" --resume old', + RESUME, + 'powershell' + ) + ).toBe(`claude --allowedTools "Bash(git:*)" '--resume' '${SESSION_ID}'`) + }) + + it.each([ + 'claude "--add-dir" "C:\\a\\" "--" "--out" "D:\\x\\"', + 'claude --add-dir "C:\\repo\\" --resume old' + ])('fails open when a cmd backslash run makes a quote literal: %s', (base) => { + // An odd run of backslashes makes the quote a literal byte to the child's + // CommandLineToArgvW parser, so tokenizer boundaries stop matching argv. + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe( + `${base} "--resume" "${SESSION_ID}"` + ) + }) + + it.each([ + 'claude --add-dir "C:\\Users\\me\\repo" --resume old', + 'claude --add-dir "C:\\Program Files (x86)\\x" --resume old' + ])('still strips for ordinary quoted windows paths: %s', (base) => { + const command = buildClaudeResumeLaunchCommand(base, RESUME, 'cmd') + expect(command).not.toContain('--resume old') + expectSingleAuthoritativeResume(command, 'cmd') + }) + + it('fails open when a cmd caret escapes a real argument separator', () => { + // cmd strips ^ before the child re-splits on the bare space, so the + // tokenizer's merged token would drop the user's second argument. + const base = 'claude --resume a^ b --model x' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe( + `${base} "--resume" "${SESSION_ID}"` + ) + }) + + it.each([ + ['claude --resume old \\', 'posix' as const], + ['claude --resume old `', 'powershell' as const], + ['claude --resume old ^', 'cmd' as const] + ])('fails open on a trailing unpaired escape: %s', (base, shell) => { + // A dangling escape would swallow the separator before the appended + // selector, so claude would receive no exact --resume at all. + const quoted = shell === 'cmd' ? `"--resume" "${SESSION_ID}"` : `'--resume' '${SESSION_ID}'` + expect(buildClaudeResumeLaunchCommand(base, RESUME, shell)).toBe(`${base} ${quoted}`) + }) + + it('fails open on a windows escaped line continuation', () => { + const powershellBase = 'claude --resume stale `\n--resume hidden' + expect(buildClaudeResumeLaunchCommand(powershellBase, RESUME, 'powershell')).toBe( + `${powershellBase} '--resume' '${SESSION_ID}'` + ) + const cmdBase = 'claude --resume stale ^\n--resume hidden' + expect(buildClaudeResumeLaunchCommand(cmdBase, RESUME, 'cmd')).toBe( + `${cmdBase} "--resume" "${SESSION_ID}"` + ) + }) + + it('fails open on a posix line continuation hiding a selector', () => { + const base = 'claude --model x \\\n--resume old' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on escape characters the windows shells keep literal', () => { + // cmd keeps ^ literal inside double quotes; PowerShell keeps ` literal + // inside single quotes, so these tokens are not really selectors. + expect(buildClaudeResumeLaunchCommand('claude "-^-resume" old', RESUME, 'cmd')).toBe( + `claude "-^-resume" old "--resume" "${SESSION_ID}"` + ) + expect(buildClaudeResumeLaunchCommand("claude '-`-resume' old", RESUME, 'powershell')).toBe( + `claude '-\`-resume' old '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on an unclosed expansion opened before claude', () => { + const base = '$(x; npx -- claude --resume stale)' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open when the executable token itself is unmodelable', () => { + // cmd has no single-quote syntax, so 'claude' is not the claude executable. + expect(buildClaudeResumeLaunchCommand("'claude' --resume old", RESUME, 'cmd')).toBe( + `'claude' --resume old "--resume" "${SESSION_ID}"` + ) + }) + + it.each(['cmd', 'powershell'] as const)( + 'fails open on a posix-style assignment prefix under %s', + (shell) => { + // NAME=value prefixes are posix-only syntax; on Windows shells that + // token is a bogus executable, so nothing may be spliced. + const base = "FOO='bar' claude --resume old" + const quoted = shell === 'cmd' ? `"--resume" "${SESSION_ID}"` : `'--resume' '${SESSION_ID}'` + expect(buildClaudeResumeLaunchCommand(base, RESUME, shell)).toBe(`${base} ${quoted}`) + } + ) + + it('preserves an env-assignment or path prefix byte for byte', () => { + expect( + buildClaudeResumeLaunchCommand('FOO="$HOME/x" ~/bin/claude \'--resume\'', RESUME, 'posix') + ).toBe(`FOO="$HOME/x" ~/bin/claude '--resume' '${SESSION_ID}'`) + }) + + it('recognizes Windows claude executable spellings', () => { + expect(buildClaudeResumeLaunchCommand('C:\\tools\\claude.CMD "--resume"', RESUME, 'cmd')).toBe( + `C:\\tools\\claude.CMD "--resume" "${SESSION_ID}"` + ) + }) + + it("inserts the selector before claude's own -- terminator", () => { + expect( + buildClaudeResumeLaunchCommand('claude --resume stale -- positional', RESUME, 'posix') + ).toBe(`claude '--resume' '${SESSION_ID}' -- positional`) + }) + + it('fails open when the base cannot be tokenized', () => { + expect(buildClaudeResumeLaunchCommand('claude "unterminated', RESUME, 'posix')).toBe( + `claude "unterminated '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open when no claude executable token exists', () => { + expect(buildClaudeResumeLaunchCommand('my-agent-wrapper --resume', RESUME, 'posix')).toBe( + `my-agent-wrapper --resume '--resume' '${SESSION_ID}'` + ) + }) + + it.each([ + 'claude -c && echo done', + 'claude --resume stale; echo hi', + 'claude --resume stale;echo hi', + 'claude -c | tee /tmp/log', + 'claude --resume stale 2>/tmp/x.log', + 'claude --resume stale\n--verbose', + 'claude --resume stale # note' + ])('fails open when the base chains shell syntax after claude: %s', (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it.each([ + 'claude --resume old "y"; echo hi', + "claude --resume old 'y'; echo hi", + 'claude --resume old --model "sonnet"&&echo hi', + 'claude --resume old "y"| tee /tmp/x', + 'claude --resume old \\"; echo hi' + ])('fails open when shell syntax hides behind partial quoting: %s', (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on escape-adjacent operators in windows shells', () => { + const powershellBase = 'claude --resume old `x; echo hi' + expect(buildClaudeResumeLaunchCommand(powershellBase, RESUME, 'powershell')).toBe( + `${powershellBase} '--resume' '${SESSION_ID}'` + ) + const cmdBase = 'claude --resume old ^x& echo hi' + expect(buildClaudeResumeLaunchCommand(cmdBase, RESUME, 'cmd')).toBe( + `${cmdBase} "--resume" "${SESSION_ID}"` + ) + }) + + it.each([ + 'claude --resume $(cat sid.txt)', + 'claude --resume=$(cat sid.txt)', + 'claude --resume `cat sid.txt`', + 'claude --resume ${SID:-a b}' + ])('fails open on unquoted multi-token shell expansions: %s', (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it.each([ + 'claude --resume "`cat "a b"`"', + 'claude --model "$(pick "x -c y")"', + 'claude --model "$(f "x --resume y")"', + 'claude --resume "$(cat "$HOME/My Sessions/id")"' + ])('fails open on expansions nested inside double quotes: %s', (base) => { + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on a powershell double-quoted subexpression', () => { + const base = 'claude --model "$(pick "x -c y")"' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('fails open on a powershell subexpression locator', () => { + const base = 'claude --resume $(Get-Content sid.txt)' + expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe( + `${base} '--resume' '${SESSION_ID}'` + ) + }) + + it('never walks the separator backoff into an escaped-space token', () => { + expect( + buildClaudeResumeLaunchCommand( + 'claude --append-system-prompt Be\\ nice\\ --resume OLD', + RESUME, + 'posix' + ) + ).toBe(`claude --append-system-prompt Be\\ nice\\ '--resume' '${SESSION_ID}'`) + }) + + it('fails open when cmd operators hide in single quotes', () => { + // cmd.exe has no single-quote syntax, so '&' is a live command separator. + expect(buildClaudeResumeLaunchCommand("claude '&' --resume=old-session", RESUME, 'cmd')).toBe( + `claude '&' --resume=old-session "--resume" "${SESSION_ID}"` + ) + }) + + it('fails open on cmd single-quoted literals that merely look like selectors', () => { + // cmd passes the quotes through, so these are junk positionals for + // claude, not real selectors or terminators. + expect(buildClaudeResumeLaunchCommand("claude '--resume' old", RESUME, 'cmd')).toBe( + `claude '--resume' old "--resume" "${SESSION_ID}"` + ) + expect(buildClaudeResumeLaunchCommand("claude '--' --resume old", RESUME, 'cmd')).toBe( + `claude '--' --resume old "--resume" "${SESSION_ID}"` + ) + }) + + it('still strips next to a caret-escaped literal ampersand on cmd', () => { + // ^& is an inactive, literal & in cmd, so the guard may keep working. + expect(buildClaudeResumeLaunchCommand('claude --resume old ^&', RESUME, 'cmd')).toBe( + `claude ^& "--resume" "${SESSION_ID}"` + ) + }) + + it('still strips when operators are safely quoted', () => { + expect( + buildClaudeResumeLaunchCommand( + "claude --append-system-prompt 'use && wisely' --resume stale", + RESUME, + 'posix' + ) + ).toBe(`claude --append-system-prompt 'use && wisely' '--resume' '${SESSION_ID}'`) + }) + + it('recognizes claude behind the PowerShell call operator', () => { + expect(buildClaudeResumeLaunchCommand("& claude '--resume'", RESUME, 'powershell')).toBe( + `& claude '--resume' '${SESSION_ID}'` + ) + expect( + buildClaudeResumeLaunchCommand( + "& 'C:\\Program Files\\claude\\claude.exe' --resume old", + RESUME, + 'powershell' + ) + ).toBe(`& 'C:\\Program Files\\claude\\claude.exe' '--resume' '${SESSION_ID}'`) + }) +}) + +describe('buildAgentResumeStartupPlan claude selector guard', () => { + it.each(SHELLS)( + 'emits one identity-bearing resume for a persisted bare selector ($platform/$shell)', + ({ platform, shell }) => { + const initial = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + agentArgs: '--resume', + platform, + shell, + allowEmptyPromptLaunch: true + }) + expect(initial).not.toBeNull() + const restored = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession, + cmdOverrides: {}, + agentArgs: initial?.launchConfig.agentArgs, + agentCommand: initial?.launchConfig.agentCommand, + platform, + shell + }) + expect(restored).not.toBeNull() + expectSingleAuthoritativeResume(restored?.launchCommand ?? '', shell) + } + ) + + it('emits one identity-bearing resume when only default args carry a stale id', () => { + const restored = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession, + cmdOverrides: {}, + agentArgs: '--resume stale-session --model sonnet', + platform: 'linux' + }) + expect(restored?.launchCommand).toBe(`claude '--model' 'sonnet' '--resume' '${SESSION_ID}'`) + }) + + it('still launches exotic custom commands that the tokenizer rejects', () => { + const restored = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession, + cmdOverrides: {}, + agentCommand: 'claude --model $(cat ~/.claude-model) "unterminated', + platform: 'darwin' + }) + expect(restored).not.toBeNull() + expect(restored?.launchCommand.endsWith(`'--resume' '${SESSION_ID}'`)).toBe(true) + }) + + it('does not change other agents', () => { + const restored = buildAgentResumeStartupPlan({ + agent: 'gemini', + providerSession, + cmdOverrides: {}, + agentArgs: '--resume', + platform: 'linux' + }) + expect(restored?.launchCommand).toBe(`gemini '--resume' '--resume' '${SESSION_ID}'`) + }) + + it('persists the original base command unchanged', () => { + const restored = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession, + cmdOverrides: {}, + agentCommand: "claude '--resume'", + platform: 'linux' + }) + expect(restored?.launchConfig.agentCommand).toBe("claude '--resume'") + }) +}) diff --git a/src/shared/agent-resume-launch-command.ts b/src/shared/agent-resume-launch-command.ts new file mode 100644 index 00000000000..1093735d266 --- /dev/null +++ b/src/shared/agent-resume-launch-command.ts @@ -0,0 +1,168 @@ +import type { ResumableTuiAgent } from './agent-session-resume' +import { + quoteStartupArg, + tokenizeStartupCommand, + type AgentStartupShell +} from './tui-agent-startup-shell' + +function isClaudeResumeSelector(token: string): boolean { + if (token === '--resume' || token.startsWith('--resume=')) { + return true + } + if (token === '--continue' || token.startsWith('--continue=')) { + return true + } + // Why: the joined -r form is deliberately NOT matched — any `-r…` token + // is ambiguous with another option's dash-leading value (`--agent -review`), + // and no arity table can keep up with the CLI. Only exact selector shapes + // are stripped; a persisted joined form degrades to pre-guard behavior. + return token === '-r' || token.startsWith('-r=') || token === '-c' || token.startsWith('-c=') +} + +function isClaudeExecutableToken(token: string): boolean { + const base = token.split(/[\\/]/).pop() ?? '' + return /^claude(\.(exe|cmd|bat|ps1))?$/i.test(base) +} + +/** Accepts a claude token only in command position — index 0, right after a + * wrapper's `--`, behind PowerShell's `&` call operator, or preceded solely by + * NAME=value assignments — so an argument that merely ends in /claude (an ssh + * key, a project dir) can never be mistaken for the executable. */ +function findClaudeExecutableIndex(tokens: readonly string[], shell: AgentStartupShell): number { + let commandPosition = true + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i] + if (commandPosition) { + if (isClaudeExecutableToken(token)) { + return i + } + if ( + // Why: `NAME=value cmd` is posix-only syntax; on cmd/PowerShell such a + // token is just a bogus executable name, not a prefix to skip. + (shell === 'posix' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) || + (shell === 'powershell' && token === '&' && i === 0) + ) { + continue + } + commandPosition = false + } + if (token === '--') { + commandPosition = true + } + } + return -1 +} + +/** Joins the resolved base command with the agent's resume argv. Claude goes + * through the selector guard below; other agents keep plain appending. */ +export function buildAgentResumeLaunchCommand( + agent: ResumableTuiAgent, + baseCommand: string, + resumeArgv: readonly string[], + shell: AgentStartupShell +): string { + const argv = resumeArgv.slice(1) + if (agent === 'claude') { + return buildClaudeResumeLaunchCommand(baseCommand, argv, shell) + } + const resumeArgs = argv.map((arg) => quoteStartupArg(arg, shell)).join(' ') + return resumeArgs ? `${baseCommand} ${resumeArgs}` : baseCommand +} + +/** Builds the Claude cold-restore launch command: strips any resume/continue + * selector the user's persisted command carries and appends exactly one + * authoritative selector, so a stale or bare selector can never compete with + * the provider session id (#12982). + * + * Fails open by design: when the base command cannot be tokenized, or no + * claude executable token can be located (wrapper commands like + * `bash -c claude`), the base is left byte-for-byte untouched and the + * selector is appended, which is the pre-guard behavior. Bytes outside + * removed selector tokens are always preserved verbatim — the base is + * spliced by source span, never re-quoted. */ +export function buildClaudeResumeLaunchCommand( + baseCommand: string, + resumeArgs: readonly string[], + shell: AgentStartupShell +): string { + const quotedResume = resumeArgs.map((arg) => quoteStartupArg(arg, shell)).join(' ') + if (!quotedResume) { + return baseCommand + } + const appended = `${baseCommand} ${quotedResume}` + const tokenized = tokenizeStartupCommand(baseCommand, shell) + if (!tokenized.ok) { + return appended + } + const { tokens, spans } = tokenized + const claudeIndex = findClaudeExecutableIndex(tokens, shell) + if (claudeIndex === -1) { + return appended + } + // Why: any token the tokenizer cannot model for this shell — an operator, + // comment, expansion, or cmd single-quoted region — means the splice could + // cut live syntax or misread a literal as a selector. The whole base must + // be modelable, including the executable itself; only PowerShell's leading + // call operator is a known-safe divergent token. + for (let i = 0; i <= tokens.length; i += 1) { + const gapStart = i === 0 ? 0 : spans[i - 1].end + const gapEnd = i === tokens.length ? baseCommand.length : spans[i].start + if (!/^[ \t]*$/.test(baseCommand.slice(gapStart, gapEnd))) { + return appended + } + if (i === tokens.length) { + break + } + // Why: a bare `--%` makes PowerShell pass the rest of the line to the + // child literally, so appended quoting would arrive as literal bytes. A + // quoted `--%` can also stop parsing, but only before a parameter token, + // where the base is already mangled with or without the guard. + if (shell === 'powershell' && baseCommand.slice(spans[i].start, spans[i].end) === '--%') { + return appended + } + if (spans[i].divergesFromShell) { + const isCallOperator = shell === 'powershell' && i === 0 && tokens[i] === '&' + if (!isCallOperator) { + return appended + } + } + } + const cuts: { start: number; end: number }[] = [] + let terminatorStart: number | null = null + for (let i = claudeIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i] + if (token === '--') { + // Why: claude is the executable here, so `--` is claude's own + // terminator; the selector must stay in option position before it. + // Span-splice equivalent of insertBeforeTerminator in + // tui-agent-launch-command.ts, which re-quotes and cannot be reused. + terminatorStart = spans[i].start + break + } + if (!isClaudeResumeSelector(token)) { + continue + } + // Why: absorb the separator before the selector, but never cross into the + // previous token, whose span can end with an escaped-space byte. + let start = spans[i].start + while (start > spans[i - 1].end && ' \t'.includes(baseCommand[start - 1])) { + start -= 1 + } + let end = spans[i].end + const next = tokens[i + 1] + if ((token === '--resume' || token === '-r') && next !== undefined && !next.startsWith('-')) { + // A stale session locator rides along with its selector. + end = spans[i + 1].end + i += 1 + } + cuts.push({ start, end }) + } + let result = baseCommand + if (terminatorStart !== null) { + result = `${result.slice(0, terminatorStart)}${quotedResume} ${result.slice(terminatorStart)}` + } + for (let i = cuts.length - 1; i >= 0; i -= 1) { + result = `${result.slice(0, cuts[i].start)}${result.slice(cuts[i].end)}` + } + return terminatorStart !== null ? result : `${result} ${quotedResume}` +} diff --git a/src/shared/commit-message-prompt.test.ts b/src/shared/commit-message-prompt.test.ts index 407b604f76d..ba2a975190f 100644 --- a/src/shared/commit-message-prompt.test.ts +++ b/src/shared/commit-message-prompt.test.ts @@ -250,27 +250,67 @@ describe('excerptAgentFailureOutput', () => { describe('tokenizeCustomCommandTemplate', () => { it('splits on whitespace', () => { const r = tokenizeCustomCommandTemplate('claude -p') - expect(r).toEqual({ ok: true, tokens: ['claude', '-p'] }) + expect(r).toEqual({ ok: true, tokens: ['claude', '-p'], spans: expect.any(Array) }) }) it('groups double-quoted segments with spaces', () => { const r = tokenizeCustomCommandTemplate('claude --msg "hello world"') - expect(r).toEqual({ ok: true, tokens: ['claude', '--msg', 'hello world'] }) + expect(r).toEqual({ + ok: true, + tokens: ['claude', '--msg', 'hello world'], + spans: expect.any(Array) + }) }) it('groups single-quoted segments verbatim', () => { const r = tokenizeCustomCommandTemplate(`agent --json '{"k":"v"}'`) - expect(r).toEqual({ ok: true, tokens: ['agent', '--json', '{"k":"v"}'] }) + expect(r).toEqual({ + ok: true, + tokens: ['agent', '--json', '{"k":"v"}'], + spans: expect.any(Array) + }) }) it('honors backslash escapes inside double quotes', () => { const r = tokenizeCustomCommandTemplate('claude --msg "she said \\"hi\\""') - expect(r).toEqual({ ok: true, tokens: ['claude', '--msg', 'she said "hi"'] }) + expect(r).toEqual({ + ok: true, + tokens: ['claude', '--msg', 'she said "hi"'], + spans: expect.any(Array) + }) }) it('keeps adjacent quoted/unquoted regions in one token (a"b"c → abc)', () => { const r = tokenizeCustomCommandTemplate('foo a"b"c') - expect(r).toEqual({ ok: true, tokens: ['foo', 'abc'] }) + expect(r).toEqual({ ok: true, tokens: ['foo', 'abc'], spans: expect.any(Array) }) + }) + + it('always reports one span per token', () => { + for (const source of ['claude -p', 'claude --msg "hello world"', 'foo a"b"c', ' \t ']) { + const r = tokenizeCustomCommandTemplate(source) + expect(r.ok && r.spans.length).toBe(r.ok && r.tokens.length) + } + }) + + it('reports source spans covering each raw token including quotes', () => { + const source = 'claude --msg "hello world"' + const r = tokenizeCustomCommandTemplate(source) + expect(r).toEqual({ + ok: true, + tokens: ['claude', '--msg', 'hello world'], + spans: [ + { start: 0, end: 6, divergesFromShell: false }, + { start: 7, end: 12, divergesFromShell: false }, + { start: 13, end: 26, divergesFromShell: false } + ] + }) + if (r.ok) { + expect(r.spans.map(({ start, end }) => source.slice(start, end))).toEqual([ + 'claude', + '--msg', + '"hello world"' + ]) + } }) it('returns an error for an unclosed quote', () => { @@ -283,7 +323,7 @@ describe('tokenizeCustomCommandTemplate', () => { it('returns an empty token list for whitespace-only input', () => { const r = tokenizeCustomCommandTemplate(' \t ') - expect(r).toEqual({ ok: true, tokens: [] }) + expect(r).toEqual({ ok: true, tokens: [], spans: [] }) }) }) diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index 4a034d2d53f..82dc830fd59 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -131,8 +131,16 @@ export function truncateDiffForPrompt( export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}' +/** Source range of a token: [start, end) offsets into the original string. + * `divergesFromShell` marks a token this tokenizer cannot model faithfully for + * the target shell: an unquoted operator (`;&|<>`), a word-leading `#` + * comment, an expansion opener whose body can span tokens (backtick, `$(`, + * `${`, quoted or not), or a cmd single-quoted region (cmd has no + * single-quote syntax). Not recoverable from the token value alone. */ +export type CommandTokenSpan = { start: number; end: number; divergesFromShell: boolean } + export type TokenizeCustomCommandResult = - | { ok: true; tokens: string[] } + | { ok: true; tokens: string[]; spans: CommandTokenSpan[] } | { ok: false; error: string } // Why: deliberately POSIX-shell-style only for *grouping* (single + double @@ -143,8 +151,11 @@ export type TokenizeCustomCommandResult = // surface we don't need. export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomCommandResult { const tokens: string[] = [] + const spans: CommandTokenSpan[] = [] let current = '' let inToken = false + let tokenStart = 0 + let divergesFromShell = false let quote: '"' | "'" | null = null let i = 0 @@ -152,10 +163,17 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC const ch = template[i] if (quote) { if (ch === '\\' && quote === '"' && i + 1 < template.length) { + // Why: inside double quotes the shell only consumes the backslash + // before these; elsewhere it stays a literal byte this tokenizer drops. + divergesFromShell ||= !'$`"\\'.includes(template[i + 1]) current += template[i + 1] i += 2 continue } + // Why: a `"` inside $(…) or `…` re-opens a nested quoting context in the + // real shell, so this tokenizer's word boundaries stop matching it. + divergesFromShell ||= + quote === '"' && (ch === '`' || (ch === '$' && '({'.includes(template[i + 1] ?? '\0'))) if (ch === quote) { quote = null i++ @@ -171,13 +189,22 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC if (ch === '"' || ch === "'") { quote = ch + if (!inToken) { + tokenStart = i + } inToken = true i++ continue } if (ch === '\\' && i + 1 < template.length) { + // Why: an unquoted line continuation joins words the shell splits, so a + // selector can hide inside the joined token and skip the gap check. + divergesFromShell ||= template[i + 1] === '\n' current += template[i + 1] + if (!inToken) { + tokenStart = i + } inToken = true i += 2 continue @@ -186,13 +213,25 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC if (/\s/.test(ch)) { if (inToken) { tokens.push(current) + spans.push({ start: tokenStart, end: i, divergesFromShell }) current = '' inToken = false + divergesFromShell = false } i++ continue } + if (!inToken) { + tokenStart = i + } + // Why: a trailing unpaired escape swallows whatever a consumer appends + // after the base, so the base is not safe to build on. + divergesFromShell ||= ch === '\\' && i + 1 >= template.length + divergesFromShell ||= + ';&|<>`'.includes(ch) || + (ch === '#' && !inToken) || + (ch === '$' && '({\'"'.includes(template[i + 1] ?? '\0')) current += ch inToken = true i++ @@ -203,8 +242,9 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC } if (inToken) { tokens.push(current) + spans.push({ start: tokenStart, end: template.length, divergesFromShell }) } - return { ok: true, tokens } + return { ok: true, tokens, spans } } export type CustomCommandPlan = diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index 3ce20571f4e..c45ac67bd19 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -43,7 +43,7 @@ export function resolveAgentLaunchCommand(args: { } const trailingTokens = args.agentArgs?.trim() ? tokenizeStartupCommand(args.agentArgs.trim(), args.shell) - : { ok: true as const, tokens: [] } + : { ok: true as const, tokens: [], spans: [] } if (!trailingTokens.ok) { return { ok: false, error: `CLI arguments are invalid: ${trailingTokens.error}` } } diff --git a/src/shared/tui-agent-startup-shell.test.ts b/src/shared/tui-agent-startup-shell.test.ts new file mode 100644 index 00000000000..40c8f4ac8ee --- /dev/null +++ b/src/shared/tui-agent-startup-shell.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +function expectSpansCoverTokens(source: string, shell: 'powershell' | 'cmd'): string[] { + const result = tokenizeStartupCommand(source, shell) + expect(result.ok).toBe(true) + if (!result.ok) { + return [] + } + expect(result.spans).toHaveLength(result.tokens.length) + let previousEnd = 0 + for (const [index, { start, end }] of result.spans.entries()) { + expect(start).toBeGreaterThanOrEqual(previousEnd) + expect(end).toBeGreaterThan(start) + // Every raw span must re-tokenize to exactly its own token. + const slice = tokenizeStartupCommand(source.slice(start, end), shell) + expect(slice.ok && slice.tokens).toEqual([result.tokens[index]]) + previousEnd = end + } + return result.spans.map(({ start, end }) => source.slice(start, end)) +} + +describe('tokenizeStartupCommand spans (windows shells)', () => { + it('covers plain and quoted tokens on powershell', () => { + const source = "claude --msg 'hello world'" + const result = tokenizeStartupCommand(source, 'powershell') + expect(result).toEqual({ + ok: true, + tokens: ['claude', '--msg', 'hello world'], + spans: [ + { start: 0, end: 6, divergesFromShell: false }, + { start: 7, end: 12, divergesFromShell: false }, + { start: 13, end: 26, divergesFromShell: false } + ] + }) + }) + + it('starts a span at a token-leading escape character', () => { + expect(expectSpansCoverTokens('claude ^&literal next', 'cmd')).toEqual([ + 'claude', + '^&literal', + 'next' + ]) + expect(expectSpansCoverTokens('claude `x tail', 'powershell')).toEqual(['claude', '`x', 'tail']) + }) + + it('spans a powershell doubled-quote token as one raw range', () => { + expect(expectSpansCoverTokens("claude 'a''b' end", 'powershell')).toEqual([ + 'claude', + "'a''b'", + 'end' + ]) + }) + + it('spans a token opened by a quote at end of input', () => { + expect(expectSpansCoverTokens('claude ""', 'cmd')).toEqual(['claude', '""']) + }) +}) diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index 8bdb5465845..744e5a1a27f 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -1,28 +1,74 @@ -import { tokenizeCustomCommandTemplate } from './commit-message-prompt' +import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt' export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' -export type StartupCommandTokens = { ok: true; tokens: string[] } | { ok: false; error: string } +export type StartupCommandTokens = + | { ok: true; tokens: string[]; spans: CommandTokenSpan[] } + | { ok: false; error: string } + +/** True when an odd run of backslashes precedes this quote, which makes it a + * literal byte to the child's CommandLineToArgvW parser rather than a + * delimiter — so cmd's word boundaries stop matching this tokenizer's. */ +function hasOddBackslashRun(value: string, quoteIndex: number): boolean { + let backslashes = 0 + while (value[quoteIndex - 1 - backslashes] === '\\') { + backslashes += 1 + } + return backslashes % 2 === 1 +} function tokenizeWindowsStartupCommand( value: string, shell: Exclude ): StartupCommandTokens { const tokens: string[] = [] + const spans: CommandTokenSpan[] = [] let token = '' + let tokenStart = 0 + let divergesFromShell = false let quote: "'" | '"' | null = null let tokenStarted = false for (let index = 0; index < value.length; index += 1) { const char = value[index] const escape = shell === 'cmd' ? '^' : '`' if (char === escape && index + 1 < value.length) { + // Why: cmd strips `^` and hands the bare byte to the child's parser, + // which re-splits on whitespace and reopens a quote, and keeps the caret + // literal inside double quotes — either way the token stops matching + // argv. PowerShell folds the backtick into the token except for LF line + // continuations, verbatim single quotes, escape sequences, and a + // token-leading backtick before whitespace, which it drops entirely. + // A bare CR is treated as unmodelable rather than folded: pwsh 7 keeps + // it in the token, Windows PowerShell 5.1 is unverified, and failing + // open there costs nothing. + divergesFromShell ||= + (shell === 'cmd' ? /[\s"]/.test(value[index + 1]) : /[\n\r]/.test(value[index + 1])) || + (shell === 'cmd' && quote === '"') || + (shell === 'powershell' && quote === "'") || + // A token-leading backtick before whitespace is dropped with the + // whitespace, emitting no token at all rather than the one built here. + (shell === 'powershell' && !tokenStarted && /\s/.test(value[index + 1])) || + // PowerShell expands these escape sequences in quoted AND bare + // arguments, so the token value this branch builds is not argv's. + (shell === 'powershell' && '0abefnrtuv'.includes(value[index + 1])) token += value[index + 1] + if (!tokenStarted) { + tokenStart = index + } tokenStarted = true index += 1 continue } if (quote) { + // Why: see the posix tokenizer — a `"` inside $(…) re-opens a nested + // quoting context that this tokenizer does not model. + divergesFromShell ||= + shell === 'powershell' && + quote === '"' && + char === '$' && + (value[index + 1] === '(' || value[index + 1] === '{') if (char === quote) { + divergesFromShell ||= shell === 'cmd' && char === '"' && hasOddBackslashRun(value, index) if (shell === 'powershell' && quote === "'" && value[index + 1] === "'") { token += "'" index += 1 @@ -36,15 +82,39 @@ function tokenizeWindowsStartupCommand( continue } if (char === "'" || char === '"') { + divergesFromShell ||= shell === 'cmd' && char === '"' && hasOddBackslashRun(value, index) quote = char + // Why: cmd.exe has no single-quote syntax, so this tokenizer's grouping + // of a single-quoted region diverges from what cmd actually parses; + // flag the token so consumers treat it as unmodelable. + divergesFromShell ||= shell === 'cmd' && char === "'" + if (!tokenStarted) { + tokenStart = index + } tokenStarted = true } else if (/\s/.test(char)) { if (tokenStarted) { tokens.push(token) + spans.push({ start: tokenStart, end: index, divergesFromShell }) token = '' tokenStarted = false + divergesFromShell = false } } else { + if (!tokenStarted) { + tokenStart = index + } + // Why: see the posix tokenizer — a trailing unpaired escape would + // swallow the separator before anything appended to the base. + divergesFromShell ||= char === escape && index + 1 >= value.length + divergesFromShell ||= + ';&|<>'.includes(char) || + (shell === 'powershell' && + // Why: bare (…) is evaluated and {…} is a script block in argument + // position, so both are live syntax the span splice cannot model. + ('(){}'.includes(char) || + (char === '#' && !tokenStarted) || + (char === '$' && (value[index + 1] === '(' || value[index + 1] === '{')))) token += char tokenStarted = true } @@ -54,8 +124,9 @@ function tokenizeWindowsStartupCommand( } if (tokenStarted) { tokens.push(token) + spans.push({ start: tokenStart, end: value.length, divergesFromShell }) } - return { ok: true, tokens } + return { ok: true, tokens, spans } } export function tokenizeStartupCommand( diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 27d4b40ac8d..4cef246ccdb 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -20,6 +20,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './types' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' export type AgentStartupPlan = { agent: TuiAgent @@ -222,14 +223,9 @@ export function buildAgentResumeStartupPlan(args: { ...args, agentCommand: baseCommand.command }) - const resumeArgs = argv - .slice(1) - .map((arg) => quoteStartupArg(arg, shell)) - .join(' ') - const launchCommand = resumeArgs ? `${baseCommand.command} ${resumeArgs}` : baseCommand.command return { agent: args.agent, - launchCommand, + launchCommand: buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell), expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, From 09c8597fb7cf35bfcbc7b8487d078891172fd7d6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:17:17 -0700 Subject: [PATCH 003/109] perf(orchestration): route federated reads over shared control (#13814) --- ...environment-federated-read-routing.test.ts | 173 ++++++++++++++++++ ...ent-federated-read-transport.bench.test.ts | 116 ++++++++++++ ...runtime-environment-request-connections.ts | 15 +- .../runtime-environment-transport-routing.ts | 47 ++++- src/shared/remote-runtime-client.ts | 10 +- src/shared/remote-runtime-memory-limits.ts | 9 +- ...-runtime-shared-control-connection.test.ts | 31 ++++ ...emote-runtime-shared-control-connection.ts | 9 +- .../remote-runtime-shared-control-requests.ts | 6 +- 9 files changed, 390 insertions(+), 26 deletions(-) create mode 100644 src/main/ipc/runtime-environment-federated-read-routing.test.ts create mode 100644 src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts new file mode 100644 index 00000000000..09e649e73f5 --- /dev/null +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -0,0 +1,173 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' + +const { sendRemoteRuntimeRequestMock, sendRemoteRuntimeSharedControlRequestMock } = vi.hoisted( + () => ({ + sendRemoteRuntimeRequestMock: vi.fn(), + sendRemoteRuntimeSharedControlRequestMock: vi.fn() + }) +) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock +})) + +vi.mock('./runtime-environment-request-connections', () => ({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + reconnectRemoteRuntimeSharedControlConnection: vi.fn() +})) + +import { + callRuntimeEnvironment, + resetSharedControlSupport +} from './runtime-environment-transport-routing' + +describe('federated read RPC transport routing', () => { + let userDataPath: string + let environmentId: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-federated-read-routing-')) + environmentId = addEnvironmentFromPairingCode(userDataPath, { + name: 'worker', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }).id + resetSharedControlSupport() + sendRemoteRuntimeRequestMock.mockReset() + sendRemoteRuntimeSharedControlRequestMock.mockReset() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('routes an enveloped federation read over shared control with its authority intact', async () => { + const envelope = { + orchestrationCapability: 'capability', + orchestrationContractVersion: 1, + orchestrationRequestId: 'request-1', + compatibilityInvocationId: 'compatibility-1', + orchestrationCompatibilityEvidence: { + terminalHandle: 'term-1', + paneKey: 'pane-1', + launchToken: 'launch-1' + } + } + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, + _meta: { runtimeId: 'runtime-worker' } + }) + sendRemoteRuntimeSharedControlRequestMock.mockResolvedValue({ + id: 'pull', + ok: true, + result: { runtimeEpoch: 'runtime-worker', items: [] }, + _meta: { runtimeId: 'runtime-worker' } + }) + + await expect( + callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + undefined, + envelope + ) + ).resolves.toMatchObject({ ok: true }) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual(['status.get']) + expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith( + environmentId, + expect.any(Object), + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + envelope + ) + }) + + it('keeps enveloped federation mutations on the one-shot transport', async () => { + const envelope = { orchestrationContractVersion: 1, orchestrationRequestId: 'ack-1' } + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'ack', + ok: true, + result: { acknowledgedThrough: 4 }, + _meta: { runtimeId: 'runtime-worker' } + }) + + await callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationAck', + { dispatchId: 'dispatch-1', throughSequence: 4 }, + 15_000, + undefined, + envelope + ) + + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.any(Object), + 'orchestration.federationAck', + { dispatchId: 'dispatch-1', throughSequence: 4 }, + 15_000, + envelope + ) + expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() + }) + + it('falls back to one-shot with the envelope when shared control is unavailable', async () => { + const envelope = { orchestrationContractVersion: 1 } + sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => + method === 'status.get' + ? { + id: 'status', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'runtime-worker' } + } + : { + id: 'pull', + ok: true, + result: { runtimeEpoch: 'runtime-worker', items: [] }, + _meta: { runtimeId: 'runtime-worker' } + } + ) + + await callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + undefined, + envelope + ) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'status.get', + 'orchestration.federationPull' + ]) + expect(sendRemoteRuntimeRequestMock).toHaveBeenLastCalledWith( + expect.any(Object), + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + envelope + ) + expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts b/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts new file mode 100644 index 00000000000..4543c3c9b34 --- /dev/null +++ b/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' +import { RuntimeEnvironmentStoreSchema } from '../../shared/runtime-environments' + +const runLiveBenchmark = process.env.ORCA_FEDERATED_READ_BENCH === '1' + +it('interpolates even-sized benchmark samples', () => expect(percentile([1, 3], 0.5)).toBe(2)) + +describe.runIf(runLiveBenchmark)('federated read RPC transport benchmark', () => { + it('compares one-shot and shared-control latency on one saved runtime', async () => { + const userDataPath = process.env.ORCA_RUNTIME_USER_DATA_PATH + const environmentName = process.env.ORCA_RUNTIME_ENVIRONMENT + if (!userDataPath || !environmentName) { + throw new Error('Set ORCA_RUNTIME_USER_DATA_PATH and ORCA_RUNTIME_ENVIRONMENT.') + } + const store = RuntimeEnvironmentStoreSchema.parse( + JSON.parse(readFileSync(join(userDataPath, 'orca-environments.json'), 'utf8')) + ) + const environment = store.environments.find((entry) => entry.name === environmentName) + if (!environment) { + throw new Error(`Unknown runtime environment: ${environmentName}`) + } + const endpoint = + environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ?? + environment.endpoints[0] + if (!endpoint) { + throw new Error(`Runtime environment ${environmentName} has no endpoint.`) + } + const pairing = { + v: 2 as const, + endpoint: endpoint.endpoint, + deviceToken: endpoint.deviceToken, + publicKeyB64: endpoint.publicKeyB64 + } + const params = { + dispatchId: 'sta3880_benchmark_missing', + afterSequence: 0, + limit: 50 + } + const oneShot = await measureRequests(() => + sendRemoteRuntimeRequest(pairing, 'orchestration.federationPull', params, 15_000, { + orchestrationContractVersion: 1 + }) + ) + const shared = new RemoteRuntimeSharedControlConnection(pairing, { + environmentId: environment.id + }) + const sharedControl = await measureRequests(() => + shared.request('orchestration.federationPull', params, 15_000, { + orchestrationContractVersion: 1 + }) + ) + const diagnostics = shared.getDiagnostics() + shared.close() + + expect(diagnostics).toMatchObject({ state: 'ready', pendingRequestCount: 0 }) + process.stdout.write(`${JSON.stringify({ oneShot, sharedControl }, null, 2)}\n`) + }, 120_000) +}) + +async function measureRequests( + request: () => Promise<{ ok: boolean; error?: { code: string } }>, + count = 30 +): Promise<{ + count: number + meanMs: number + medianMs: number + p95Ms: number + minMs: number + maxMs: number + responseCodes: string[] +}> { + for (let index = 0; index < 3; index++) { + await request() + } + const durations: number[] = [] + const responseCodes = new Set() + for (let index = 0; index < count; index++) { + const startedAt = performance.now() + const response = await request() + durations.push(performance.now() - startedAt) + if (!response.ok && response.error) { + responseCodes.add(response.error.code) + } + } + durations.sort((left, right) => left - right) + return { + count, + meanMs: round(durations.reduce((sum, value) => sum + value, 0) / count), + medianMs: round(percentile(durations, 0.5)), + p95Ms: round(percentile(durations, 0.95)), + minMs: round(durations[0] ?? 0), + maxMs: round(durations.at(-1) ?? 0), + responseCodes: [...responseCodes] + } +} + +function percentile(sorted: number[], fraction: number): number { + if (sorted.length === 0) { + return 0 + } + const rank = (sorted.length - 1) * fraction + const lowerIndex = Math.floor(rank) + const upperIndex = Math.ceil(rank) + const lower = sorted[lowerIndex] ?? 0 + const upper = sorted[upperIndex] ?? lower + return lower + (upper - lower) * (rank - lowerIndex) +} + +function round(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 946a0d30cf8..58fb95dd6e1 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,5 +1,8 @@ import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' import type { @@ -52,9 +55,15 @@ export function sendRemoteRuntimeSharedControlRequest( pairing: PairingOffer, method: string, params: unknown, - timeoutMs: number + timeoutMs: number, + envelope?: RuntimeOrchestrationEnvelope ): Promise> { - return getSharedControlConnection(environmentId, pairing).request(method, params, timeoutMs) + return getSharedControlConnection(environmentId, pairing).request( + method, + params, + timeoutMs, + envelope + ) } export function subscribeRemoteRuntimeSharedControlRequest( diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index d144ee087e1..884b0b2874a 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,5 +1,6 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse @@ -108,7 +109,8 @@ export async function callRuntimeEnvironment( const pairing = getPreferredPairingOffer(currentEnvironment) endpoint = pairing.endpoint const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS - if (envelope) { + const sharedControlEnvelope = shouldUseSharedControlEnvelope(method, params, envelope) + if (envelope && !sharedControlEnvelope) { const response = await sendRemoteRuntimeRequest( pairing, method, @@ -135,19 +137,36 @@ export async function callRuntimeEnvironment( !shouldUseOneShotRequest(method) && (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) ) { - const response = await sendRemoteRuntimeSharedControlRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) + const response = sharedControlEnvelope + ? await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs, + sharedControlEnvelope + ) + : await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response } // Why: startup/control-plane RPCs use the proven one-shot path so repo // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + const response = sharedControlEnvelope + ? await sendRemoteRuntimeRequest( + pairing, + method, + params, + effectiveTimeoutMs, + sharedControlEnvelope + ) + : await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response }) @@ -253,6 +272,16 @@ function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } +function shouldUseSharedControlEnvelope( + method: string, + params: unknown, + envelope: RuntimeOrchestrationEnvelope | undefined +): RuntimeOrchestrationEnvelope | undefined { + return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) + ? envelope + : undefined +} + function shouldUseOneShotRequest(method: string): boolean { // Why: snapshot recovery must remain available while a retained shared-control stream is reconnecting after a HUB restart. return method === 'session.tabs.list' || method === 'session.tabs.listAll' diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index f147ed4e7bb..c53565dc8a2 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -144,16 +144,12 @@ async function sendRemoteRuntimeRequestOnSocket( }) const pendingRequest = { preparedRequest: prepareRemoteRuntimeRequest(new Map(), () => - serializeRemoteRuntimePayload({ - id: requestId, + serializeRemoteRuntimeRpcRequest({ + requestId, deviceToken: pairing.deviceToken, method, params, - orchestrationCapability: envelope?.orchestrationCapability, - orchestrationContractVersion: envelope?.orchestrationContractVersion, - orchestrationRequestId: envelope?.orchestrationRequestId, - compatibilityInvocationId: envelope?.compatibilityInvocationId, - orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence + envelope }) ) } diff --git a/src/shared/remote-runtime-memory-limits.ts b/src/shared/remote-runtime-memory-limits.ts index 8152f58ef66..a41409ffdfe 100644 --- a/src/shared/remote-runtime-memory-limits.ts +++ b/src/shared/remote-runtime-memory-limits.ts @@ -3,6 +3,7 @@ import { stringifyJsonWithinByteLimit } from './node-bounded-json-stringify' import { RemoteRuntimeClientError } from './remote-runtime-client-error' +import type { RuntimeOrchestrationEnvelope } from './runtime-rpc-envelope' export const REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES = 4 * 1024 * 1024 export const REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES = 8 * 1024 * 1024 + 64 @@ -63,12 +64,18 @@ export function serializeRemoteRuntimeRpcRequest(args: { deviceToken: string method: string params: unknown + envelope?: RuntimeOrchestrationEnvelope }): string { return serializeRemoteRuntimePayload({ id: args.requestId, deviceToken: args.deviceToken, method: args.method, - params: args.params + params: args.params, + orchestrationCapability: args.envelope?.orchestrationCapability, + orchestrationContractVersion: args.envelope?.orchestrationContractVersion, + orchestrationRequestId: args.envelope?.orchestrationRequestId, + compatibilityInvocationId: args.envelope?.compatibilityInvocationId, + orchestrationCompatibilityEvidence: args.envelope?.orchestrationCompatibilityEvidence }) } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 66a57a92e52..f85b1d8fa6b 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -78,6 +78,37 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) + it('preserves orchestration authority fields on shared-control requests', async () => { + const server = await createServer() + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + const envelope = { + orchestrationCapability: 'capability', + orchestrationContractVersion: 1, + orchestrationRequestId: 'request-1', + compatibilityInvocationId: 'compatibility-1', + orchestrationCompatibilityEvidence: { + terminalHandle: 'term-1', + paneKey: 'pane-1', + launchToken: 'launch-1' + }, + id: 'forged-id', + deviceToken: 'forged-token', + method: 'orchestration.federationAck', + params: { dispatchId: 'forged-dispatch' } + } + + await connection.request('orchestration.federationPull', {}, 1000, envelope) + + expect(server.requests).toContainEqual({ + ...envelope, + id: expect.any(String), + deviceToken: 'device-token', + method: 'orchestration.federationPull', + params: {} + }) + connection.close() + }) + it('does not expose a binary sender on the shared control protocol surface', () => { expect('sendSharedControlEncryptedBinary' in sharedControlProtocol).toBe(false) }) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 524b6697931..4fb40525245 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -1,6 +1,5 @@ import WebSocket from 'ws' import type { PairingOffer } from './pairing' -import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import { openSharedControlSocket } from './remote-runtime-shared-control-open' @@ -66,14 +65,16 @@ export class RemoteRuntimeSharedControlConnection { request( method: string, params: unknown, - timeoutMs: number - ): Promise> { - return requestSharedControl({ + timeoutMs: number, + envelope?: Parameters[0]['envelope'] + ): ReturnType> { + return requestSharedControl({ pendingRequests: this.pendingRequests, deviceToken: this.pairing.deviceToken, method, params, timeoutMs, + envelope, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId) => this.sendRequest(requestId), retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 8379e53da91..5c55c3d11b6 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -6,7 +6,7 @@ import { type RemoteRuntimePreparedRequest } from './remote-runtime-prepared-request-admission' import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' -import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' import type { SharedControlPendingRequest } from './remote-runtime-shared-control-types' @@ -19,6 +19,7 @@ export function requestSharedControl(args: { method: string params: unknown timeoutMs: number + envelope?: RuntimeOrchestrationEnvelope ensureReady: () => Promise send: (requestId: string) => void retireRequestId?: (requestId: string) => void @@ -35,7 +36,8 @@ export function requestSharedControl(args: { requestId, deviceToken: args.deviceToken, method: args.method, - params: args.params + params: args.params, + envelope: args.envelope }) ) } catch (error) { From ff727c3caf0535ab26e4316f037b53c482b0bb8f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:30:42 -0700 Subject: [PATCH 004/109] fix(settings): read voice settings from a ref, not a stale closure (#13883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): read voice settings from a ref, not a stale closure updateVoiceSettings wrote from a closure captured at mount, so an async continuation could clobber a newer value. Uses the latest-ref pattern already in Settings.tsx:405-407. Found while investigating voice-microphone-selection.spec.ts:107 and confirmed NOT to be its cause — that failure has no established root cause. The spec gains a hydration barrier and toBeEnabled assertions here, which improve its failure message but do not fix it. Co-authored-by: Orca * test(e2e): drop the voice spec changes from this PR The hydration poll was a no-op: prepareVoiceSettings awaits updateSettings inside the same page.evaluate, so the store is already committed before the poll samples it, in all three tests (the reload precedes it). Worse, its comment asserted that the Select is disabled until hydration — the CI accessibility snapshot at the moment of failure shows the switch checked and the combobox enabled, so that theory is refuted, and a wrong 'why' comment is worse than none. The toBeEnabled assertions go with it: the control was enabled when it hung, so they would not have caught the real failure either. Leaves this PR as what it actually is — a product bug fix. The spec's failure stays open and unexplained. Co-authored-by: Orca * test(settings): type the clear-key mock as its API contract vi.fn(() => clearing) returned Promise where the preload contract is Promise<{ configured: boolean }>. Vitest does not typecheck, so this passed locally while tsc -p tsconfig.tc.web.json failed — it would have gone red in CI's required typecheck job. Co-authored-by: Orca * fix(settings): write the voice-settings ref in an effect, not during render React Doctor's 'Ref mutated during render' rule is blocking in CI's static analysis job, and it is right: React can replay or discard render work, so a render-time ref write can leak from UI that never commits. Moving it to an effect keeps the behavior this fix needs — every writer here (key-status probe, save-key, clear-key) runs post-commit, so the ref is current by the time any of them reads it. Test still discriminates: red without the fix, green with. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../components/settings/VoicePane.test.tsx | 70 +++++++++++++++++++ .../src/components/settings/VoicePane.tsx | 13 +++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/settings/VoicePane.test.tsx b/src/renderer/src/components/settings/VoicePane.test.tsx index 890d971656e..84f6bbd5393 100644 --- a/src/renderer/src/components/settings/VoicePane.test.tsx +++ b/src/renderer/src/components/settings/VoicePane.test.tsx @@ -253,4 +253,74 @@ describe('VoicePane', () => { expect(recordFeatureInteraction).not.toHaveBeenCalled() }) + + it('merges an in-flight voice write onto the newest settings, not the render-time snapshot', async () => { + const updateSettings = vi.fn() + let resolveClear: () => void = () => {} + const clearing = new Promise<{ configured: boolean }>((resolve) => { + resolveClear = () => resolve({ configured: false }) + }) + useAppStoreMock.mockImplementation((selector: (state: Record) => unknown) => + selector({ + modelStates: [], + refreshModelStates: vi.fn(), + markFeatureTipsSeen: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + ) + useShortcutLabelMock.mockReturnValue('Ctrl+Shift+Y') + installWindowApi(vi.fn(async () => deniedMicrophoneResult)) + window.api.speech.getOpenAiApiKeyStatus = vi.fn(async () => ({ configured: true })) + window.api.speech.clearOpenAiApiKey = vi.fn(() => clearing) + + const settingsWithKey = (enabled: boolean): GlobalSettings => + ({ + voice: { + ...getDefaultVoiceSettings(), + enabled, + openAiApiKeyConfigured: true, + microphoneDeviceId: 'usb-mic', + microphoneDeviceLabel: 'USB Microphone' + } + }) as GlobalSettings + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render() + }) + + const disconnect = container.querySelector( + 'button[aria-label="Disconnect OpenAI API key"]' + ) + if (!disconnect) { + throw new Error('Disconnect OpenAI API key button was not rendered') + } + await act(async () => { + disconnect.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + // The user turns dictation off while the clear-key IPC is still in flight. + await act(async () => { + root.render() + }) + + await act(async () => { + resolveClear() + await clearing + }) + root.unmount() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ + voice: { + ...getDefaultVoiceSettings(), + enabled: false, + openAiApiKeyConfigured: false, + microphoneDeviceId: 'usb-mic', + microphoneDeviceLabel: 'USB Microphone' + } + }) + }) }) diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index a1fdfbe9edd..f6729301702 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -36,6 +36,15 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const [openAiKeyPending, setOpenAiKeyPending] = useState(false) const [pendingCloudModelId, setPendingCloudModelId] = useState(null) const mountedRef = useRef(true) + // Why: every write here is a read-modify-write of the whole voice object, and the + // writers are async (key status probe, save/clear key). Merging onto the render-time + // snapshot would resurrect settings that changed while the IPC was in flight — e.g. + // reverting `enabled` to false and leaving the microphone picker permanently disabled. + // Written in an effect, not during render: the async writers all run post-commit. + const voiceSettingsRef = useRef(voiceSettings) + useEffect(() => { + voiceSettingsRef.current = voiceSettings + }, [voiceSettings]) const handlePaneRef = useCallback((node: HTMLDivElement | null): void => { mountedRef.current = node !== null @@ -45,12 +54,12 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J (updates: Partial): void => { updateSettings({ voice: { - ...voiceSettings, + ...voiceSettingsRef.current, ...updates } }) }, - [updateSettings, voiceSettings] + [updateSettings] ) useEffect(() => { From f19ff5be68061ac35518d900ee74d24ef5c18318 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:37:53 -0700 Subject: [PATCH 005/109] fix(browser): keep one identity across hosts during Google sign-in (STA-3811) (#13667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the auth document is on screen the WebContents UA is Firefox, so its cross-host subresource/XHR requests (gstatic, play.google.com, the sign-in challenge endpoints) reached the header layer carrying the Firefox UA yet still bearing Chromium client hints, which the else-branch rewrote to Chrome. That paired a Firefox UA with Chrome client hints on every non-auth Google host — a sharper cross-host identity tell than either signal alone, and a plausible cause of the password-submit challenge greying out and stalling. Strip client hints on any request already carrying the Firefox auth UA so the UA and hint surfaces tell one Firefox story for the whole flow. Gated on the same googleAuthOverride flag as the auth-host switch, so imported-native profiles are unaffected and the clean-Chrome default for non-Google sites (Cloudflare) is untouched. Extends tests/tools/google-signin-ua-probe.cjs with app-current/app-fixed modes that mirror the shipped code and log per-request identity; on the real accounts.google.com load they show 18 firefox-ua-with-chrome-hints cross-host mismatches before and 0 after. --- src/main/browser/browser-google-auth-ua.ts | 8 ++ .../browser/browser-session-registry.test.ts | 76 +++++++++++++ src/main/browser/browser-session-ua.ts | 13 +++ tests/tools/google-signin-ua-probe.cjs | 106 +++++++++++++++++- 4 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index 4392e6351c2..16ed14eb80f 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -49,3 +49,11 @@ export function setUserAgentHeader(headers: Record, value: strin const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') headers[existing ?? 'User-Agent'] = value } + +// Why: the outgoing header carries the WebContents UA override, so reading it +// (case-insensitively) tells us whether a request originated from the Firefox +// auth document even when its destination host isn't an auth host. +export function currentUserAgent(headers: Record): string | undefined { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + return existing ? headers[existing] : undefined +} diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 44e1b6b8e92..9f32e4f379d 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -28,6 +28,7 @@ vi.mock('./browser-manager', () => ({ })) import { browserSessionRegistry } from './browser-session-registry' +import { googleAuthUserAgent } from './browser-google-auth-ua' import { setupClientHintsOverride } from './browser-session-ua' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { @@ -440,6 +441,81 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua-platform']).toBeUndefined() }) + it('strips client hints on a cross-host request that carries the Firefox auth UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Subresource/XHR to a non-auth Google host while the auth document is on + // screen: the WebContents Firefox UA leaks onto the request header. + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { + 'User-Agent': googleAuthUserAgent(), + 'sec-ch-ua': 'old', + 'sec-ch-ua-full-version-list': 'old', + 'sec-ch-ua-platform': '"macOS"', + 'sec-ch-ua-mobile': '?0' + } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + // UA stays Firefox and every client hint is dropped — one consistent identity. + expect(modified['User-Agent']).toBe(googleAuthUserAgent()) + expect(modified['sec-ch-ua']).toBeUndefined() + expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() + expect(modified['sec-ch-ua-platform']).toBeUndefined() + expect(modified['sec-ch-ua-mobile']).toBeUndefined() + }) + + it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, chromeUa) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Regression guard: non-Google sites (Cloudflare) must keep Chrome hints. + listener( + { + url: 'https://example.com/api', + requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' } + }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + + it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' } + }, + callback + ) + // Imported-native profiles never install the Firefox switch, so the strip + // branch stays inert and hints are aligned to Chrome instead. + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 092b79995d8..96c55cf5ac9 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,6 +1,7 @@ import type { Session } from 'electron' import { + currentUserAgent, googleAuthUserAgent, isGoogleAuthUrl, setUserAgentHeader, @@ -48,6 +49,18 @@ export function setupClientHintsOverride( callback({ requestHeaders: headers }) return } + if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) { + // Why: while the auth document is on screen the WebContents UA is Firefox, + // so its cross-host subresource/XHR requests (gstatic, play.google.com, the + // sign-in challenge endpoints) reach here carrying the Firefox UA yet still + // bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox + // UA with Chrome hints — a sharper cross-host identity tell than either + // alone, which can stall Google's password-submit challenge. Real Firefox + // sends no client hints, so strip them to keep one identity for the flow. + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } if (chromeHints) { for (const key of Object.keys(headers)) { const lower = key.toLowerCase() diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 4fe50a2c122..7adf06bb7a0 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -9,13 +9,27 @@ const MODES = new Set([ 'electron-auth', 'electron-fixed', 'firefox-auth', - 'firefox-fixed' + 'firefox-fixed', + // Replicates the SHIPPED app exactly (setupClientHintsOverride + + // applyGoogleAuthUserAgent): Firefox UA is written to the WebContents on auth + // navs and to the request header only for auth-host URLs; every other request + // keeps whatever UA the WebContents carries. Logs incoming vs outgoing + // identity for ALL requests to expose cross-host mismatches during the flow. + 'app-current', + // Same, but with the STA-3811 fix: the header layer strips client hints on any + // request already carrying the Firefox auth UA, regardless of destination host. + 'app-fixed' ]) const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) if (!mode || !MODES.has(mode)) { throw new Error(`Expected --mode=${[...MODES].join('|')}`) } +const headless = process.argv.includes('--headless') +const exitAfterMs = Number( + process.argv.find((arg) => arg.startsWith('--exit-after-ms='))?.slice('--exit-after-ms='.length) +) + const profileRoot = mkdtempSync(join(tmpdir(), `orca-google-signin-${mode}-`)) const partition = `persist:google-signin-${mode}` app.setPath('userData', profileRoot) @@ -58,6 +72,30 @@ function safeUrl(rawUrl) { } } +function hostOf(rawUrl) { + try { + return new URL(rawUrl).hostname.toLowerCase() + } catch { + return '' + } +} + +// A Firefox UA paired with any sec-ch-ua header is the cross-host tell we hunt: +// real Firefox emits no client hints, so the two surfaces contradict each other. +function detectUaHintMismatch(headers) { + const ua = headers['user-agent'] || '' + const isFirefox = /Firefox\/\d/.test(ua) && !/Chrome\//.test(ua) + const hasHints = Object.keys(headers).some((key) => key.startsWith('sec-ch-ua')) + if (isFirefox && hasHints) { + return 'firefox-ua-with-chrome-hints' + } + const isChrome = /Chrome\/\d/.test(ua) + if (isChrome && !hasHints) { + return 'chrome-ua-without-hints' + } + return null +} + function setHeader(headers, name, value) { const existing = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase()) headers[existing ?? name] = value @@ -126,8 +164,47 @@ app.whenReady().then(async () => { identities.cleaned = cleanElectronUserAgent(identities.native) browserSession.setUserAgent(identityForUrl('about:blank', identities)) + const isAppMode = mode === 'app-current' || mode === 'app-fixed' + browserSession.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { const headers = details.requestHeaders + const incoming = relevantHeaders(headers) + + if (isAppMode) { + // Mirror setupClientHintsOverride: only auth-host URLs get the Firefox UA + // header + hint strip; every other request keeps its incoming UA (which is + // the WebContents UA — Firefox while the auth document is on screen). + if (isGoogleAuthUrl(details.url)) { + setHeader(headers, 'user-agent', identities.firefox) + removeClientHints(headers) + } else { + const currentUa = incoming['user-agent'] + // STA-3811 fix: a request already carrying the Firefox auth UA came from + // the auth document; real Firefox sends no client hints, so strip them + // instead of rewriting to Chrome — keeping UA and hints one story. + if (mode === 'app-fixed' && currentUa === identities.firefox) { + removeClientHints(headers) + } else { + // Real setupClientHintsOverride builds Chrome hints once from the + // session's cleaned UA (a closure), never from the per-request UA. + applyChromeClientHints(headers, identities.cleaned) + } + } + const outgoing = relevantHeaders(headers) + const uaMismatch = detectUaHintMismatch(outgoing) + log('request', { + url: safeUrl(details.url), + host: hostOf(details.url), + resourceType: details.resourceType, + isAuthHost: isGoogleAuthUrl(details.url), + incoming, + outgoing, + uaHintMismatch: uaMismatch + }) + callback({ requestHeaders: headers }) + return + } + const identity = identityForUrl(details.url, identities) setHeader(headers, 'user-agent', identity) if (identity === identities.firefox) { @@ -144,8 +221,15 @@ app.whenReady().then(async () => { callback({ requestHeaders: headers }) }) + if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { + setTimeout(() => { + log('exit', { reason: 'timeout', exitAfterMs }) + app.quit() + }, exitAfterMs) + } + const window = new BrowserWindow({ - show: true, + show: !headless, title: `Google sign-in UA probe: ${mode}`, width: 980, height: 840, @@ -161,6 +245,24 @@ app.whenReady().then(async () => { if (!isMainFrame) { return } + if (isAppMode) { + // Mirror applyGoogleAuthUserAgent: Firefox UA on auth navs, restore the + // cleaned session UA otherwise. This WebContents UA is what leaks onto + // cross-host subresource requests while the auth document is on screen. + const current = window.webContents.getUserAgent() + if (isGoogleAuthUrl(url)) { + if (current !== identities.firefox) { + window.webContents.setUserAgent(identities.firefox) + } + } else if (current === identities.firefox) { + window.webContents.setUserAgent(identities.cleaned) + } + log('main-frame-navigation', { + url: safeUrl(url), + appliedUserAgent: window.webContents.getUserAgent() + }) + return + } const identity = identityForUrl(url, identities) if (window.webContents.getUserAgent() !== identity) { window.webContents.setUserAgent(identity) From 686f5dca1a4fc181b7d77042d1d6089dd57ffb9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:39:17 -0700 Subject: [PATCH 006/109] fix(browser): exclude Google cookies from imports - direct sign-in is the only Google path (STA-3811) (#13670) * fix(browser): exclude Google cookies from imports (STA-3811) Imports never write and never remove a google.com-family cookie, on any path. Signing in directly inside Orca is the only Google session that survives, so the live jar always beats anything an import could plant. * fix(browser): roll back selective cookie clears --- ...ser-cookie-import-google-exclusion.test.ts | 239 ++++++++++++++++++ .../browser-cookie-import-policy.test.ts | 161 ++++++++++++ .../browser/browser-cookie-import-policy.ts | 80 ++++++ .../browser-cookie-import-replacement.test.ts | 27 +- .../browser/browser-cookie-import.test.ts | 5 +- src/main/browser/browser-cookie-import.ts | 43 +++- ...r-cookie-import-google-disclosure.test.tsx | 138 ++++++++++ .../browser-toolbar-menu-dropdown.tsx | 7 + .../components/settings/BrowserProfileRow.tsx | 7 + src/renderer/src/i18n/locales/en.json | 6 +- 10 files changed, 687 insertions(+), 26 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-google-exclusion.test.ts create mode 100644 src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts new file mode 100644 index 00000000000..24472f8c828 --- /dev/null +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -0,0 +1,239 @@ +/** + * STA-3811: an import must never write and never remove a google.com-family cookie, on any + * path. Removing 'google.com' from NON_TRANSPLANTABLE_DOMAINS flips every test here red. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + clearPendingCookieImportMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +function existingCookie(domain: string, name: string) { + return { domain, name, path: '/', secure: true, sameSite: 'unspecified', value: 'live' } +} + +const INCOMING = [ + { domain: '.google.com', name: 'SAPISID', value: 'transplanted', secure: true }, + { domain: 'accounts.google.com', name: '__Secure-1PSID', value: 'transplanted', secure: true }, + { domain: '.youtube.com', name: 'LOGIN_INFO', value: 'transplanted', secure: true }, + { domain: '.linear.app', name: 'session', value: 'new', secure: true } +] + +describe('file import excludes the Google cookie family', () => { + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + vi.clearAllMocks() + tmpDir = mkdtempSync(join(tmpdir(), 'orca-google-exclusion-file-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReturnValue({ + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookies(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('writes no Google cookie and removes none, whatever is already in the jar', async () => { + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('accounts.google.com', '__Secure-1PSID'), + existingCookie('.linear.app', 'old-linear') + ]) + + const result = await importCookiesFromFile(writeCookies(INCOMING), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 4, + importedCookies: 2, + skippedCookies: 2, + domains: ['linear.app', 'youtube.com'] + }) + expect(cookiesRemoveMock.mock.calls).toEqual([['https://linear.app/', 'old-linear']]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([ + '.youtube.com', + '.linear.app' + ]) + }) + + it('leaves Google cookies from an older import in place too', async () => { + cookiesGetMock.mockResolvedValue([existingCookie('.google.com', 'SAPISID')]) + + const result = await importCookiesFromFile( + writeCookies([{ domain: '.google.com', name: 'SAPISID', value: 'newer', secure: true }]), + 'persist:test' + ) + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + domains: [] + }) + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + +describe('native Chromium import excludes the Google cookie family', () => { + let clearStorageDataMock: ReturnType + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + let platformSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + tmpDir = mkdtempSync(join(tmpdir(), 'orca-google-exclusion-native-')) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + execFileSyncMock.mockImplementation(() => { + throw new Error('OS browser version lookup unavailable') + }) + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + get: cookiesGetMock, + remove: cookiesRemoveMock, + set: cookiesSetMock + }, + clearStorageData: clearStorageDataMock + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedSource(rows: { domain: string; name: string; value: string }[]): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, rows).close() + return sourceCookiesPath + } + + function seedTarget(rows: { domain: string; name: string; value: string }[]): void { + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(targetCookiesPath, rows).close() + } + + it('never wipes the jar wholesale and keeps the live Google cookies', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([{ domain: '.google.com', name: 'SID', value: 'live-sid' }]) + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('.example.com', 'stale') + ]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 2, + importedCookies: 1, + skippedCookies: 1, + domains: ['example.com'] + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'stale']]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com']) + }) + + it('keeps the live Google rows in the staged restart-fallback database', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([ + { domain: '.google.com', name: 'SID', value: 'live-sid' }, + { domain: '.example.com', name: 'stale', value: 'stale' } + ]) + // Why: a rejected set() forces the staged DB to be kept for cold-restart replay. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const [partition, stagedPath] = setPendingCookieImportMock.mock.calls[0] + expect(partition).toBe('persist:test') + expect(readStagedRows(stagedPath)).toEqual([ + { host_key: '.example.com', name: 'session', value: 'new' }, + { host_key: '.google.com', name: 'SID', value: 'live-sid' } + ]) + }) +}) + +// Why: freshly imported rows store the decrypted value as a BLOB; read both forms as text. +function readStagedRows(stagedPath: string): { host_key: string; name: string; value: string }[] { + const stagedDb = new DatabaseSync(stagedPath, { readOnly: true }) + const rows = stagedDb + .prepare('SELECT host_key, name, value FROM cookies ORDER BY host_key, name') + .all() as { host_key: string; name: string; value: string | Uint8Array }[] + stagedDb.close() + return rows.map((row) => ({ + ...row, + value: typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('latin1') + })) +} + +function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 31ca329bf8f..0969921724c 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from 'vitest' +import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains } from './browser-cookie-import-policy' @@ -147,3 +151,160 @@ describe('replaceCookiesForImportedDomains', () => { }) }) }) + +describe('isNonTransplantableCookieDomain', () => { + it('covers the whole google.com registrable family', () => { + expect(isNonTransplantableCookieDomain('google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('.google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('accounts.google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('MAIL.Google.Com')).toBe(true) + }) + + it('does not match lookalikes or unrelated sites', () => { + expect(isNonTransplantableCookieDomain('withgoogle.com')).toBe(false) + expect(isNonTransplantableCookieDomain('google.com.evil.example')).toBe(false) + expect(isNonTransplantableCookieDomain('notgoogle.com')).toBe(false) + expect(isNonTransplantableCookieDomain('linear.app')).toBe(false) + expect(isNonTransplantableCookieDomain('')).toBe(false) + }) + + // Why: youtube.com re-issues its cookies from a transplanted session, so excluding it would + // drop imports users asked for. Locking it in keeps a future "just add it too" edit honest. + it('deliberately leaves youtube.com transplantable', () => { + expect(isNonTransplantableCookieDomain('.youtube.com')).toBe(false) + expect(isNonTransplantableCookieDomain('accounts.youtube.com')).toBe(false) + }) +}) + +describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => { + it('selects the google.com family and nothing that merely looks like it', () => { + const db = new DatabaseSync(':memory:') + db.exec('CREATE TABLE cookies (host_key TEXT)') + for (const hostKey of [ + 'google.com', + '.google.com', + 'accounts.google.com', + 'withgoogle.com', + 'google.com.evil.example', + '.youtube.com', + '.linear.app' + ]) { + db.prepare('INSERT INTO cookies (host_key) VALUES (?)').run(hostKey) + } + + const matched = db + .prepare( + `SELECT host_key FROM cookies WHERE ${NON_TRANSPLANTABLE_HOST_KEY_SQL} ORDER BY host_key` + ) + .all() as { host_key: string }[] + db.close() + + expect(matched.map((row) => row.host_key)).toEqual([ + '.google.com', + 'accounts.google.com', + 'google.com' + ]) + }) +}) + +describe('removeAllCookiesExcept', () => { + it('removes only the cookies the predicate does not exclude', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.google.com', 'SID'), + cookie('.example.com', 'session'), + cookie('other.test', 'tracker', '/scoped') + ]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + await removeAllCookiesExcept({ get, remove, set }, (c) => c.domain === '.google.com') + + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://other.test/scoped', 'tracker'] + ]) + expect(set).not.toHaveBeenCalled() + }) + + it('restores every successfully removed cookie when another removal fails', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two'), + cookie('.example.com', 'third', '/three') + ]) + const remove = vi.fn().mockImplementation(async (_url: string, name: string) => { + if (name === 'second') { + throw new Error('store unavailable') + } + }) + const set = vi.fn().mockResolvedValue(undefined) + + await expect(removeAllCookiesExcept({ get, remove, set }, () => false)).rejects.toThrow( + 'Could not clear existing cookies' + ) + expect(remove).toHaveBeenCalledTimes(3) + expect(set.mock.calls.map(([details]) => details.name)).toEqual(['first', 'third']) + }) + + it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => { + const get = vi + .fn() + .mockResolvedValue( + Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`)) + ) + let releaseRemovals: (() => void) | undefined + const removalsReleased = new Promise((resolve) => { + releaseRemovals = resolve + }) + let active = 0 + let maxActive = 0 + const remove = vi.fn().mockImplementation(async () => { + active++ + maxActive = Math.max(maxActive, active) + await removalsReleased + active-- + }) + const set = vi.fn().mockResolvedValue(undefined) + + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) + expect(maxActive).toBe(8) + releaseRemovals?.() + await clearing + + expect(remove).toHaveBeenCalledTimes(12) + expect(set).not.toHaveBeenCalled() + }) + + it('serializes cookies that share Electron removal coordinates', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.example.com', 'session'), + { ...cookie('example.com', 'session'), hostOnly: true } + ]) + let releaseFirst: (() => void) | undefined + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve + }) + const remove = vi + .fn() + .mockImplementationOnce(() => firstReleased) + .mockResolvedValueOnce(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) + releaseFirst?.() + await clearing + + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://example.com/', 'session'] + ]) + }) +}) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 216542f08ea..8f6008ac7a4 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,5 +1,6 @@ import type { Cookie, Cookies } from 'electron' import { parse as parseDomain } from 'psl' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -53,6 +54,36 @@ export function normalizeCookieImportDomain(domain: string): string | null { return normalized } +// Why (STA-3811): registrable families whose sessions are device-bound server-side, so a +// transplanted cookie is rejected (or flagged and expired within ~1h) no matter how faithfully +// it is copied. Signing in directly inside Orca is the only path that produces a working +// session, so an import must never write these cookies and never remove them either — the +// live session is always more valuable than anything an import could put in its place. +// Adding a site is one entry here. +// youtube.com is deliberately NOT listed: YouTube accepts a transplanted session and re-issues +// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports +// users actually asked for. +const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const +const COOKIE_CLEAR_CONCURRENCY = 8 + +export function isNonTransplantableCookieDomain(domain: string): boolean { + const normalized = normalizeCookieDomain(domain) + if (!normalized) { + return false + } + return NON_TRANSPLANTABLE_DOMAINS.some( + (root) => normalized === root || normalized.endsWith(`.${root}`) + ) +} + +// Why: Chromium stores host_key lowercase as 'google.com', '.google.com' or 'sub.google.com'; +// the LIKE pattern covers the leading-dot row and cannot match lookalikes ('withgoogle.com'). +export const NON_TRANSPLANTABLE_HOST_KEY_SQL = NON_TRANSPLANTABLE_DOMAINS.map( + (root) => `host_key = '${root}' OR host_key LIKE '%.${root}'` +).join(' OR ') + +// Why: subsumed by the domain exclusion above for google.com — kept because it is the general +// rule for rotation-only cookies and applies to any family added without a full exclusion. export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) { return false @@ -165,6 +196,55 @@ export async function restoreImportedDomainCookies( } } +// Why: clearStorageData wipes the whole jar, including the non-transplantable families an +// import is never allowed to remove; this is the clear step that can leave them in place. +export async function removeAllCookiesExcept( + store: Pick, + isExcluded: (cookie: Cookie) => boolean +): Promise { + const existingCookies = await store.get({}) + const removableGroups = new Map() + for (const cookie of existingCookies) { + if (isExcluded(cookie)) { + continue + } + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + const key = JSON.stringify([url, cookie.name]) + const group = removableGroups.get(key) ?? [] + group.push({ cookie, url }) + removableGroups.set(key, group) + } + + const removedCookies: Cookie[] = [] + const results = await mapSettledWithConcurrency( + [...removableGroups.values()], + COOKIE_CLEAR_CONCURRENCY, + async (group) => { + // Why: identical remove keys must stay ordered so duplicate scoped cookies are not raced. + for (const { cookie, url } of group) { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } + } + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length === 0) { + return + } + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed') + } + throw new AggregateError(failures, 'Could not clear existing cookies') +} + export async function replaceCookiesForImportedDomains( store: Pick, importedDomains: readonly string[] diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index 74c2ec432f1..79553d3a651 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -59,10 +59,11 @@ describe('validated cookie replacement', () => { return filePath } - it('filters Google source-bound cookies before replacing imported domain scopes', async () => { + it('keeps Google out of the replace scope so its cookies are never removed', async () => { cookiesGetMock.mockResolvedValue([ cookie('.google.com', 'old-google'), cookie('.accounts.google.com', 'old-accounts', '/signin'), + cookie('.example.com', 'old-example'), cookie('.unrelated.com', 'keep'), cookie('.google.com.evil.example', 'keep-suffix-confusion') ]) @@ -76,15 +77,12 @@ describe('validated cookie replacement', () => { expect(result.ok && result.summary).toMatchObject({ totalCookies: 3, - importedCookies: 2, - skippedCookies: 1, - domains: ['example.com', 'google.com'] + importedCookies: 1, + skippedCookies: 2, + domains: ['example.com'] }) - expect(cookiesRemoveMock.mock.calls).toEqual([ - ['https://google.com/', 'old-google'], - ['https://accounts.google.com/signin', 'old-accounts'] - ]) - expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC']) + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'old-example']]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SIDCC']) expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan( Math.min(...cookiesSetMock.mock.invocationCallOrder) ) @@ -185,6 +183,7 @@ describe('native Chromium integrity-cookie accounting', () => { sessionFromPartitionMock.mockReset().mockReturnValue({ cookies: { flushStore: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), set: cookiesSetMock }, @@ -196,7 +195,7 @@ describe('native Chromium integrity-cookie accounting', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - it('includes domain-scoped integrity cookies in skippedCookies', async () => { + it('counts every excluded Google cookie in skippedCookies', async () => { const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') createChromiumCookieTestDatabase(sourceCookiesPath, [ @@ -214,11 +213,11 @@ describe('native Chromium integrity-cookie accounting', () => { ) expect(result.ok && result.summary).toMatchObject({ totalCookies: 3, - importedCookies: 2, - skippedCookies: 1, - domains: ['example.com', 'google.com'] + importedCookies: 1, + skippedCookies: 2, + domains: ['example.com'] }) - expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC']) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['AEC']) } finally { platformSpy.mockRestore() } diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index aefdd27fbf5..50e232c0b7e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -445,6 +445,7 @@ describe('importCookiesFromBrowser Chromium', () => { sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ cookies: { + get: vi.fn().mockResolvedValue([]), set: cookiesSetMock, remove: cookiesRemoveMock, flushStore: cookiesFlushStoreMock @@ -510,7 +511,9 @@ describe('importCookiesFromBrowser Chromium', () => { ['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix)) ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + // Why: STA-3811 — the pre-import clear is selective now, so a wholesale wipe would + // take the non-transplantable families with it. + expect(clearStorageDataMock).not.toHaveBeenCalled() // Why: STA-3514 — imports must never impersonate the source browser; the // session keeps the engine UA the registry set at startup. expect(setUserAgentMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 66a3afdb3cf..bffb12cfa02 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -76,8 +76,11 @@ import type { import { browserSessionRegistry } from './browser-session-registry' import { isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, normalizeCookieImportDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains, restoreImportedDomainCookies, type CookieImportMode @@ -571,13 +574,19 @@ async function importValidatedCookies( } return valid }) - const importableCookies = validDomainCookies.filter( + const sourceBoundFiltered = validDomainCookies.filter( (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) ) - const integritySkipped = validDomainCookies.length - importableCookies.length + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"` + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` ) const targetSession = session.fromPartition(targetPartition) let importedCount = 0 @@ -1548,7 +1557,10 @@ export async function importCookiesFromBrowser( const targetCols: string[] = targetColumnInfo.map((r) => r.name) colList = targetCols.join(', ') placeholders = targetCols.map(() => '?').join(', ') - stagingDb.exec('DELETE FROM cookies') + // Why: the staged DB replaces the whole live DB at cold start, so it is a clear step + // like any other — keep the live non-transplantable rows in it rather than replaying + // a wipe the in-memory path was not allowed to perform. + stagingDb.exec(`DELETE FROM cookies WHERE NOT (${NON_TRANSPLANTABLE_HOST_KEY_SQL})`) } catch (err) { diag(` staging database unusable, restart fallback disabled: ${String(err)}`) stagingAvailable = false @@ -1596,6 +1608,7 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 let integritySkipped = 0 + let nonTransplantableSkipped = 0 let memoryLoaded = 0 let memoryFailed = 0 const domainSet = new Set() @@ -1669,6 +1682,12 @@ export async function importCookiesFromBrowser( continue } + // Why: transplanting these replaces a working sign-in with a session the site rejects. + if (isNonTransplantableCookieDomain(domain)) { + nonTransplantableSkipped++ + continue + } + let validDomain = sourceDomainValidity.get(domain) if (validDomain === undefined) { validDomain = normalizeCookieImportDomain(domain) !== null @@ -1718,7 +1737,9 @@ export async function importCookiesFromBrowser( // the optional staging DB is unavailable. imported++ } - diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + diag( + ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` + ) if (decryptedCookies.length === 0) { closeStagingDb() @@ -1729,7 +1750,7 @@ export async function importCookiesFromBrowser( summary: { totalCookies: sourceRows.length, importedCookies: 0, - skippedCookies: skipped + integritySkipped, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, domains: [] } } @@ -1747,8 +1768,12 @@ export async function importCookiesFromBrowser( diag(` staging skipped: ${imported} cookies will load in-memory only`) } - // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session. - await targetSession.clearStorageData({ storages: ['cookies'] }) + // Why: clear stale cookies first; mixing them with the imported set makes sites reject the + // session. Non-transplantable families are exempt — nothing was imported for them, and their + // live session is the only one that works. + await removeAllCookiesExcept(targetSession.cookies, (cookie) => + isNonTransplantableCookieDomain(cookie.domain ?? '') + ) diag( ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` ) @@ -1816,7 +1841,7 @@ export async function importCookiesFromBrowser( const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, - skippedCookies: skipped + integritySkipped, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx new file mode 100644 index 00000000000..6ef0481edab --- /dev/null +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment happy-dom + * + * STA-3811: imports never touch the Google cookie family, so both import menus must say so at + * the moment of decision. Covers the browser toolbar menu and the Settings profile row. + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@/i18n/locales/en.json' + +const DISCLOSURE = 'Google requires signing in directly - imports skip it.' + +vi.mock('@/components/ui/dropdown-menu', () => dropdownMenuStubs()) +vi.mock('../ui/dropdown-menu', () => dropdownMenuStubs()) +vi.mock('@/store', () => ({ useAppStore: appStoreStub() })) +vi.mock('../../store', () => ({ useAppStore: appStoreStub() })) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { BrowserToolbarMenuDropdown } from './browser-pane/browser-toolbar-menu-dropdown' +import { BrowserProfileRow } from './settings/BrowserProfileRow' + +const DETECTED_BROWSERS = [ + { + family: 'chrome', + label: 'Google Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +] + +describe('cookie-import Google disclosure caption', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('is shown in the browser toolbar import menu', () => { + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain(DISCLOSURE) + }) + + it('is shown in the Settings browser-profile import menu', () => { + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain(DISCLOSURE) + }) + + // Why: the rendered text comes from the catalog, not the translate() fallback, so a copy + // drift in en.json alone would otherwise slip through both render assertions. + it('reads the same copy from the catalog on both surfaces', () => { + expect(catalogEntry('auto.components.browser.pane.BrowserToolbarMenu.c186b4d890')).toBe( + DISCLOSURE + ) + expect(catalogEntry('auto.components.settings.BrowserProfileRow.654a0c2073')).toBe(DISCLOSURE) + }) +}) + +function catalogEntry(key: string): unknown { + return key + .split('.') + .reduce( + (node, part) => + typeof node === 'object' && node !== null + ? (node as Record)[part] + : undefined, + en + ) +} + +function dropdownMenuStubs(): Record { + const passthrough = ({ children }: { children?: ReactNode }): ReactNode => children + const block = ({ children }: { children?: ReactNode }): ReactNode =>
{children}
+ return { + DropdownMenu: passthrough, + DropdownMenuContent: block, + DropdownMenuItem: block, + DropdownMenuLabel: block, + DropdownMenuPortal: passthrough, + DropdownMenuRadioGroup: passthrough, + DropdownMenuRadioItem: block, + DropdownMenuSeparator: () => null, + DropdownMenuSub: passthrough, + DropdownMenuSubContent: block, + DropdownMenuSubTrigger: block, + DropdownMenuTrigger: passthrough + } +} + +function appStoreStub(): unknown { + const state = { + fetchDetectedBrowsers: vi.fn(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn() + } + const useAppStore = (selector?: (s: typeof state) => unknown): unknown => + selector ? selector(state) : state + useAppStore.getState = (): typeof state => state + return useAppStore +} diff --git a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx index b34453ba3e3..0491f55db05 100644 --- a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx @@ -4,6 +4,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -127,6 +128,12 @@ export function BrowserToolbarMenuDropdown({ + + {translate( + 'auto.components.browser.pane.BrowserToolbarMenu.c186b4d890', + 'Google requires signing in directly - imports skip it.' + )} + {detectedBrowsers.map((browser) => browser.profiles.length > 1 ? ( diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index 891164d5bd5..7ab502698ee 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, @@ -171,6 +172,12 @@ export function BrowserProfileRow({ + + {translate( + 'auto.components.settings.BrowserProfileRow.654a0c2073', + 'Google requires signing in directly - imports skip it.' + )} + {detectedBrowsers.map((browser) => browser.profiles.length > 1 ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d3ef9da104e..e8718740205 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5847,7 +5847,8 @@ "a3f8c2d1e0b4": "Imported {{value0}} cookies from {{value1}} ({{value2}}) into {{value3}}.", "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}.", "c5a273a809": "From {{value0}}", - "b5c0479e21": "Unmodified user agent" + "b5c0479e21": "Unmodified user agent", + "654a0c2073": "Google requires signing in directly - imports skip it." }, "BrowserUseComputerUseNotice": { "15b5e680ba": "Open Computer Use", @@ -14195,7 +14196,8 @@ "bf648471c5": "Creating…", "53bbe3dab4": "Imported {{value0}} cookies from file.", "c5f0e4d3b2a1": "Imported {{value0}} cookies from {{value1}} ({{value2}}).", - "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}." + "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}.", + "c186b4d890": "Google requires signing in directly - imports skip it." }, "GrabConfirmationSheet": { "314a0aaa5b": "Attach to AI", From 9f0bf39b04fa1f567157eb6817822ea681073996 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:42:56 -0700 Subject: [PATCH 007/109] fix(github): fail closed when stack metadata is unavailable (#13866) * fix(github): fail closed on unavailable stack metadata * fix(github): validate REST pull request response shape * fix(github): allow omitted stack metadata * test(github): cover enterprise stack probe failure * test(github): cover null ordinary stack metadata --------- Co-authored-by: E2E Test --- .../github/client-pr-local-runtime.test.ts | 18 + src/main/github/client.test.ts | 437 +++++++++++++++++- src/main/github/client.ts | 94 +++- 3 files changed, 527 insertions(+), 22 deletions(-) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 0e7d4013d2e..543d6f239f2 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -234,6 +234,15 @@ describe('GitHub PR local runtime routing', () => { stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' }) } } + if (args.length === 2 && endpoint === 'repos/acme/orca/pulls/7') { + return { + stdout: JSON.stringify({ + number: 7, + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' } + }) + } + } return { stdout: '', stderr: '' } }) @@ -467,6 +476,15 @@ describe('GitHub PR local runtime routing', () => { if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { return { stdout: '[]' } } + if (args.length === 2 && endpoint === 'repos/team/orca/pulls/7') { + return { + stdout: JSON.stringify({ + number: 7, + head: { ref: 'feature', sha: 'head-sha' }, + base: { ref: 'main', sha: 'base-sha' } + }) + } + } if (query) { return { stdout: JSON.stringify({ data: { repository: {} } }) } } diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index b462b77a9cd..d139c345b59 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1,6 +1,7 @@ /* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' type RateLimitGuardResult = | { blocked: false } @@ -103,6 +104,11 @@ vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, @@ -4143,6 +4149,8 @@ describe('GitHub GraphQL rate-limit guard', () => { __resetPRConflictSummaryCachesForTests() }) + afterEach(() => vi.restoreAllMocks()) + it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' @@ -4479,20 +4487,38 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) }) - it('uses async merge only for GitHub-registered stacks', async () => { + const validStackHeadSha = 'a'.repeat(40) + const validStackBaseSha = 'b'.repeat(40) + const validSha256HeadSha = 'c'.repeat(64) + + it.each([ + { + objectFormat: 'SHA-1 with a base SHA', + headSha: validStackHeadSha, + baseSha: validStackBaseSha + }, + { + objectFormat: 'SHA-256 without a base SHA', + headSha: validSha256HeadSha, + baseSha: undefined + } + ])('uses async merge only for GitHub-registered stacks using $objectFormat', async (scenario) => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 202, title: 'Stack API', state: 'open', - head: { ref: 'stack/api', sha: 'api-sha' }, + head: { ref: 'stack/api', sha: scenario.headSha }, base: { ref: 'stack/models', sha: 'models-sha' }, stack: { number: 51, position: 2, size: 2, - base: { ref: 'main', sha: 'main-sha' } + base: { + ref: 'main', + ...(scenario.baseSha ? { sha: scenario.baseSha } : {}) + } } }) }) @@ -4519,7 +4545,7 @@ describe('GitHub GraphQL rate-limit guard', () => { 'PUT', 'repos/stablyai/orca/pulls/202/merge-async', 'merge_action=merge_queue', - 'sha=api-sha' + `sha=${scenario.headSha}` ]) ) expect(mergeCall?.[0]).not.toContain('merge_method=squash') @@ -4536,13 +4562,13 @@ describe('GitHub GraphQL rate-limit guard', () => { stdout: JSON.stringify({ number: 202, state: 'open', - head: { ref: 'stack/api', sha: 'api-sha' }, + head: { ref: 'stack/api', sha: validStackHeadSha }, base: { ref: 'stack/models', sha: 'models-sha' }, stack: { number: 51, position: 2, size: 2, - base: { ref: 'main', sha: 'main-sha' } + base: { ref: 'main', sha: validStackBaseSha } } }) }) @@ -4563,6 +4589,403 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + const stackMetadataUnavailableError = + 'Could not verify GitHub pull request stack metadata. Refresh and try again.' + + it.each([ + { + failure: 'local transport failure', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: new Error('stack metadata unavailable'), + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'stack metadata unavailable', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'GitHub Enterprise transport failure', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: new Error('enterprise stack metadata unavailable'), + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'enterprise stack metadata unavailable', + expectedOptions: { cwd: '/repo-root', host: 'github.enterprise.test' } + }, + { + failure: 'unparsable probe response over SSH', + repoPath: '/remote/repo-root', + connectionId: 'ssh-1', + probeResponse: { stdout: '' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid JSON response', + expectedOptions: { host: 'github.com' } + }, + { + failure: 'valid JSON with a non-object payload', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { stdout: 'null' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid response shape', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'valid JSON with an array payload', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { stdout: '[]' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid response shape', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'present primitive stack metadata', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: true + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'malformed stack response', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: '2', + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'incoherent stack position', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 3, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'non-positive stack number', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 0, + position: 1, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'fractional stack size', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 1, + size: 1.5, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'unsafe stack number', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: Number.MAX_SAFE_INTEGER + 1, + position: 1, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'blank stack base ref', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: ' ', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'invalid stack base SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 123 } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'invalid string stack base SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'not-a-git-object-id' } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response without a head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response with an invalid head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api', sha: {} }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response with an invalid string head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api', sha: 'not-a-git-object-id' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + } + ])('fails closed on $failure', async (scenario) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + if (scenario.probeResponse instanceof Error) { + ghExecFileAsyncMock.mockRejectedValueOnce(scenario.probeResponse) + } else { + ghExecFileAsyncMock.mockResolvedValueOnce(scenario.probeResponse) + } + // Why: disabling the guard must expose the legacy merge fallthrough, not fail on an unstubbed call. + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + const result = await mergePR(scenario.repoPath, 202, 'squash', scenario.connectionId, { + owner: 'stablyai', + repo: 'orca', + host: scenario.expectedOptions.host + }) + + expect.soft(result).toEqual({ ok: false, error: scenario.expectedError }) + expect + .soft(ghExecFileAsyncMock.mock.calls.map(([args]) => args)) + .toEqual([['api', 'repos/stablyai/orca/pulls/202']]) + expect.soft(ghExecFileAsyncMock.mock.calls[0]?.[1]).toEqual(scenario.expectedOptions) + expect + .soft( + ghExecFileAsyncMock.mock.calls.some(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + ) + .toBe(false) + expect + .soft( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ) + .toBe(false) + expect.soft(acquireMock).toHaveBeenCalledTimes(1) + expect.soft(releaseMock).toHaveBeenCalledTimes(1) + expect + .soft(consoleWarnSpy) + .toHaveBeenCalledWith( + 'mergePR stack metadata probe failed for stablyai/orca#202:', + scenario.expectedDiagnostic + ) + expect.soft(consoleWarnSpy).toHaveBeenCalledTimes(1) + }) + + it.each([ + { stackShape: 'omits stack', stackField: {} }, + { stackShape: 'sets stack to null', stackField: { stack: null } } + ])('keeps legacy merge when an ordinary GitHub response $stackShape', async (scenario) => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 13866, + title: 'Fail closed on unavailable stack metadata', + state: 'open', + head: { + ref: 'sta-3924-stack-merge-fail-closed', + sha: validStackHeadSha + }, + base: { ref: 'main', sha: validStackBaseSha }, + ...scenario.stackField + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 13866, + title: 'Fail closed on unavailable stack metadata', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/13866', + statusCheckRollup: [], + updatedAt: '2026-08-11T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + baseRefOid: validStackBaseSha, + headRefName: 'sta-3924-stack-merge-fail-closed', + headRefOid: validStackHeadSha + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 13866, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '13866', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + expect(acquireMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + it('keeps legacy merge for unregistered dependent PR chains', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index f95bfe7fdd8..068d3ccc419 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -193,6 +193,8 @@ const repositoryMergeMetadataCache = new Map< >() const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 +const STACK_METADATA_UNAVAILABLE_ERROR = + 'Could not verify GitHub pull request stack metadata. Refresh and try again.' const prStackSummaryCache = new Map< string, { value: GitHubPRStack | undefined; expiresAt: number } @@ -2947,16 +2949,69 @@ async function lookupPRByBranchName(args: { } } +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +function isGitObjectId(value: unknown): value is string { + return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) +} + +function isUsableRestStackMetadata(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const stack = value as { + number?: unknown + position?: unknown + size?: unknown + base?: unknown + } + if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) { + return false + } + const base = stack.base as { ref?: unknown; sha?: unknown } + return ( + isPositiveSafeInteger(stack.number) && + isPositiveSafeInteger(stack.position) && + isPositiveSafeInteger(stack.size) && + stack.position <= stack.size && + typeof base.ref === 'string' && + base.ref.trim().length > 0 && + (base.sha === undefined || isGitObjectId(base.sha)) + ) +} + async function getRestPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType -): Promise { + ghOptions: ReturnType, + options: { requireUsableStackMetadata?: boolean } = {} +): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) - return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) + const parsed = JSON.parse(stdout) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('invalid response shape') + } + const restData = parsed as RestPullRequest + const mapped = mapRestPullRequest(restData) + if ( + options.requireUsableStackMetadata && + restData.stack !== undefined && + restData.stack !== null + ) { + // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. + if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { + throw new Error('malformed stack') + } + if (!isGitObjectId(restData.head?.sha)) { + throw new Error('missing head SHA') + } + } + return mapped } function prunePRStackSummaryCache(now = Date.now()): void { @@ -2991,7 +3046,7 @@ async function getCachedGitHubPRStackSummary( if (existing) { return existing } - const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack) + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack) prStackSummaryInFlight.set(key, request) try { const value = await request @@ -5015,19 +5070,28 @@ export async function mergePR( await acquire() let concurrencySlotHeld = true try { - let stackSummary: GitHubPRStack | undefined - let stackHeadSha: string | undefined + let restData: PullRequestLookupData try { - const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) - stackSummary = restData?.stack - stackHeadSha = restData?.headRefOid - } catch { - // GitHub remains authoritative when stack metadata cannot be read. + restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, { + requireUsableStackMetadata: true + }) + } catch (err) { + const diagnostic = + err instanceof SyntaxError + ? 'invalid JSON response' + : err instanceof Error + ? err.message + : String(err) + console.warn( + `mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`, + diagnostic + ) + return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR } } - if (stackSummary) { + if (restData.stack) { const mergeMetadata = await detectRepositoryMergeMetadata( ownerRepo, - stackSummary.baseRefName, + restData.stack.baseRefName, ghOptions, githubPRStackExecutionScope(connectionId, localGitOptions) ) @@ -5038,7 +5102,7 @@ export async function mergePR( prNumber, method, mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', - headSha: stackHeadSha, + headSha: restData.headRefOid, ghOptions }) } @@ -5185,7 +5249,7 @@ async function enablePRAutoMerge( if (ownerRepo) { try { const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) - if (restData?.stack) { + if (restData.stack) { return { ok: false, error: 'GitHub does not support auto-merge for stacked pull requests.' From 23cbe6dfe24269e380e57ad381e5a4ae23ede48a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:06:55 -0700 Subject: [PATCH 008/109] fix(agent-hooks): guard copilot and kimi Windows hooks before they own stdin (#13379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same hang class #11568 closed for .cmd: copilot-hook.ps1 ran [Console]::In.ReadToEnd() and kimi-hook.sh captured stdin before checking the Orca env, so a user-wide hook fired outside an Orca pane blocked forever when the caller abandoned the pipe — one stranded powershell.exe or bash.exe per hook event. Move the env guard (after the endpoint refresh, which can supply PORT/TOKEN) above the read on the Windows-local variants. The payload is discarded on the missing-env path anyway; the broken writer is the trade #11568 established. POSIX variants keep capture-first — those callers close stdin, and exiting mid-write there surfaces as EPIPE the agent can see (#8110). Kimi's remote install now asks for the posix variant explicitly. Verified on a real Windows host: pre-fix copilot with an abandoning caller never exits; post-fix both exit 0 immediately, and the env-present copilot path still consumes stdin and delivers the POST (paneKey + payload received by a local listener). --- src/main/agent-hooks/hook-stdin-contract.ts | 9 ++++-- .../managed-hook-stdin-lifecycle.test.ts | 31 ++++++++++--------- src/main/copilot/hook-service.ts | 5 ++- src/main/kimi/hook-service.ts | 20 ++++++++---- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index a85997d87de..fb7b3d85487 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -29,9 +29,12 @@ export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` // Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller -// may abandon stdin rather than close it — more.com then drains forever and strands a visible -// cmd.exe per hook event. Batch can exit instead because it streams to curl rather than -// capturing, so unlike the POSIX/PowerShell hooks it loses no payload by giving up stdin. +// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a +// visible window per hook event. The Windows rule: a hook must check the Orca env before it +// owns stdin, and exit without reading when the env is missing — the payload is discarded on +// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike. +// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there +// surfaces as EPIPE the agent can see (#8110). export function buildWindowsHookEnvironmentGuardLines(): string[] { return [ 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 7c27c2fadee..f805e6c25f1 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -293,13 +293,17 @@ describe('Windows managed hook stdin structure', () => { claude.indexOf('if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin') ) + // Why (#11549 class): every Windows-local hook now guards before owning stdin — + // the caller may abandon the pipe, and the payload is discarded on this path anyway. const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8') - expect(copilot.indexOf('[Console]::In.ReadToEnd()')).toBeLessThan( - copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT') + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeGreaterThan(-1) + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan( + copilot.indexOf('[Console]::In.ReadToEnd()') ) const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8') - expect(kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)).toBeLessThan( - kimi.indexOf('exit 0') + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeGreaterThan(-1) + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeLessThan( + kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) ) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) @@ -318,7 +322,7 @@ describe('Windows managed hook stdin structure', () => { }) it.skipIf(process.platform !== 'win32')( - 'exits 0 for every local script and missing-script launcher, and only .cmd drops stdin', + 'exits 0 for every local script and missing-script launcher, dropping stdin only without Orca env', async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-')) homedirMock.mockReturnValue(home) @@ -356,16 +360,13 @@ describe('Windows managed hook stdin structure', () => { : [scriptPath] const result = await runHookProcess(executable, args, hookEnvironment()) expect(result.exitCode, `${fileName} exit code`).toBe(0) - if (fileName.endsWith('.cmd')) { - // Why (#11549): these guards exit rather than own stdin, so the writer breaks — - // EPIPE, or ECONNRESET when Windows tears the pipe down first. hookEnvironment() - // strips every ORCA_* var, so the relaxation only ever covers the missing-env - // path — a happy-path case added to this loop must not reuse it. - for (const error of result.stdinErrors) { - expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) - } - } else { - expect(result.stdinErrors, `${fileName} stdin errors`).toHaveLength(0) + // Why (#11549 class): every Windows-local hook exits before owning stdin when the + // Orca env is missing, so the writer may break — EPIPE, or ECONNRESET when Windows + // tears the pipe down first. hookEnvironment() strips every ORCA_* var, so this + // relaxation only ever covers the missing-env path — a happy-path case added to + // this loop must not reuse it. + for (const error of result.stdinErrors) { + expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) } } diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 332731fe352..425efe4f00d 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -118,7 +118,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ "Write-Output '{}'", - '$inputData = [Console]::In.ReadToEnd()', // Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...` // lines so surviving PTYs can refresh to the current Orca server. 'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {', @@ -130,7 +129,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' }', ' } catch {}', '}', + // Why (#11549 class): missing Orca context means a user-wide hook fired outside an + // Orca pane. ReadToEnd blocks forever if that caller abandons the pipe, so the guard + // must run before the hook owns stdin; the payload would be discarded anyway. 'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }', + '$inputData = [Console]::In.ReadToEnd()', 'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }', 'try {', ' $payload = $inputData | ConvertFrom-Json', diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 8036330272a..a7b954f01cb 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -58,10 +58,12 @@ function getManagedCommand(scriptPath: string): string { return wrapPosixHookCommand(posixPath) } -function getManagedScript(): string { - return [ - '#!/bin/sh', - ...buildPosixHookPayloadCapture(), +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + // Why (#11549 class): on Windows this .sh runs under Git Bash but the caller is a + // Windows process that can abandon the pipe, so the missing-env guard must run before + // the capture owns stdin. POSIX callers close stdin (#8110), so posix keeps capture-first. + const windowsLocal = target === 'local' && process.platform === 'win32' + const endpointRefreshAndGuard = [ // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY // that survived an Orca restart still reaches the live listener. See // claude/hook-service.ts for the full rationale. @@ -70,7 +72,13 @@ function getManagedScript(): string { 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', - 'fi', + 'fi' + ] + return [ + '#!/bin/sh', + ...(windowsLocal + ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] + : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -212,7 +220,7 @@ export class KimiHookService { const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' const command = wrapPosixHookCommand(remoteScriptPath) // Write the script first so config.toml never points at a missing script. - await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript()) + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command)) return { agent: 'kimi', From 55beeebd25d6f0e8802e63518d4614f68354a91b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:22:15 -0700 Subject: [PATCH 009/109] Allow directly search in google (#13863) * Allow direct search with configured search engines Users can now search directly from the tab creation menu using their configured search engine. Forced search mode (`?` prefix) skips file and tab matching for guaranteed search. Refactored tab-entry operations into focused modules for clarity: forced-search parsing, network-safe selection, keyboard focus, copy strings, empty options, and props types. Search routes through the same workspace browser tab opening mechanism used for URLs, with safe title and query presentation that doesn't retain sensitive details. * Allow direct search with configured search engines - Permit search and URL navigation while file index loads; require explicit selection only when needed, not automatic opening - Block malformed IPv6 addresses in bracket notation to prevent misclassification - Support Kagi private-session links via searchUrlOptions - Improve error handling with accessibility: show error messages in status region, disable input during submission, display loading state - Fall back to local browser tab creation when remote creation fails instead of throwing; avoids remote availability blocking local search/navigation - Add error translations for all supported locales (es, ja, ko, zh) * Allow direct search with configured search engines - Extract tab create entry lifecycle to key-driven component remounting, replacing conditional state reset with useEffect cleanup - Consolidate network tab entry classification and request building into reusable helpers, eliminating duplicate logic - Simplify owner resolution by inlining logic directly into openWorkspaceBrowserTab - Replace custom surrogate-pair handling with native String.toWellFormed() for search queries - Disable explicit URL classification to prioritize search-engine queries over raw URLs * Allow direct search from quick-open tab bar entry - Single-token queries keep file matches ranked above search (quick-open intent) - Multi-word phrases promote search to top, since they cannot be file paths - Arm network actions once file index fails or text is unambiguous search - Cache prepared file index to avoid re-processing per keystroke - Generate specific tab titles (e.g. 'example.com/docs') instead of generic 'Open URL' - Surface opening workspace when launching browser tabs remotely * Use readOnly instead of disabled for pending search input Maintain keyboard focus during submission so arrow/Escape navigation continues to work. Use aria-busy to indicate loading state accessibly. Also fixes button hover styling when disabled and cleans up error message handling in the classifier. * Treat bare searches as prompts; refine path and IP classification - Bare search queries (e.g., "?") no longer display as error rows - Path prefixes with existing matches are no longer blocked mid-keystroke - Private IPv4 addresses now use http, public addresses use https - Ambiguous inputs with non-numeric ports fall through to search instead of blocking - Improve diagnostics by logging failure reasons in openFailure --- .../src/components/quick-open-search.ts | 16 + .../tab-bar/TabBarCreateEntry.search.test.tsx | 315 ++++++++++++++++++ .../components/tab-bar/TabBarCreateEntry.tsx | 235 +++++++------ .../tab-bar/TabBarCreateEntryRow.test.tsx | 15 + .../tab-bar/TabBarCreateEntryRow.tsx | 32 +- .../tab-bar/tab-create-entry-action.test.ts | 88 +++-- .../tab-bar/tab-create-entry-action.ts | 111 +++--- .../tab-create-entry-classifier.test.ts | 231 ++++++++++++- .../tab-bar/tab-create-entry-classifier.ts | 288 ++++++++-------- .../tab-bar/tab-create-entry-copy.ts | 12 + .../tab-bar/tab-create-entry-empty-options.ts | 7 + .../tab-bar/tab-create-entry-file-matches.ts | 16 +- .../tab-create-entry-forced-search.test.ts | 17 + .../tab-bar/tab-create-entry-forced-search.ts | 8 + .../tab-create-entry-keyboard-focus.ts | 18 + .../tab-create-entry-network-selection.ts | 68 ++++ .../tab-bar/tab-create-entry-props.ts | 20 ++ .../tab-create-entry-url-classification.ts | 150 ++++++--- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- .../lib/workspace-browser-tab-open.test.ts | 202 +++++++++++ .../src/lib/workspace-browser-tab-open.ts | 162 +++++++++ .../src/runtime/web-runtime-session.test.ts | 57 ++++ .../src/runtime/web-runtime-session.ts | 23 +- src/shared/browser-url.test.ts | 15 + src/shared/browser-url.ts | 2 +- 29 files changed, 1784 insertions(+), 384 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-copy.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-empty-options.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-forced-search.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-keyboard-focus.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-props.ts create mode 100644 src/renderer/src/lib/workspace-browser-tab-open.test.ts create mode 100644 src/renderer/src/lib/workspace-browser-tab-open.ts diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index db13042c8fa..690c59aae00 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -29,6 +29,22 @@ export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexe }) } +const preparedQuickOpenFiles = new WeakMap() + +// Why: keystroke-driven callers re-classify on every character, but the index +// only changes when the file list array itself is replaced. +export function getPreparedQuickOpenFiles( + files: readonly string[] +): readonly QuickOpenIndexedFile[] { + const cached = preparedQuickOpenFiles.get(files) + if (cached) { + return cached + } + const prepared = prepareQuickOpenFiles(files) + preparedQuickOpenFiles.set(files, prepared) + return prepared +} + export function isQuickOpenQueryTooLarge( query: string, maxBytes = QUICK_OPEN_QUERY_MAX_BYTES diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx new file mode 100644 index 00000000000..b40166cbc1d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx @@ -0,0 +1,315 @@ +// @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 { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import type { TabCreateMenuOption } from './tab-create-menu-options' +import { QUICK_OPEN_QUERY_MAX_BYTES } from '../quick-open-search' + +const fileListMock = vi.hoisted(() => ({ + current: { files: [] as string[], loading: false, loadError: null as string | null } +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => fileListMock.current +})) + +const tabSearchMock = vi.hoisted(() => ({ + calls: [] as { enabled: boolean; query: string }[], + results: [] as unknown[] +})) +vi.mock('./use-open-tab-search', () => ({ + useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => { + tabSearchMock.calls.push({ enabled, query }) + return { query, results: tabSearchMock.results } + } +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let container: HTMLDivElement +let root: Root + +function renderEntry(props: Record = {}): void { + act(() => { + root.render( + + + + ) + }) +} + +function setQuery(value: string): void { + const input = container.querySelector('input')! + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function submit(): void { + act(() => { + container + .querySelector('form')! + .dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +function press(key: string): void { + act(() => { + container + .querySelector('input')! + .dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) +} + +beforeEach(() => { + fileListMock.current = { files: [], loading: false, loadError: null } + tabSearchMock.calls = [] + tabSearchMock.results = [] + useAppStore.setState( + { ...useAppStore.getInitialState(), browserDefaultSearchEngine: null } as AppState, + true + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('TabBarCreateEntry search behavior', () => { + it('tracks the configured provider and submits its exact classification', () => { + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('react hooks') + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + + act(() => useAppStore.setState({ browserDefaultSearchEngine: 'bing' })) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Bing') + submit() + + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'search', engine: 'bing', query: 'react hooks' } + }) + ) + }) + + it('searches a phrase on Enter while the file index is still loading', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('natural language') + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(1) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[role="status"]')?.textContent).toContain('Loading files...') + expect(container.querySelector('[aria-selected="true"]')).not.toBeNull() + + submit() + expect(onOpenEntry).toHaveBeenCalledOnce() + }) + + it('requires an explicit choice for text the loading index could still match', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('notes.md draft') + + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.') + + press('ArrowDown') + submit() + expect(onOpenEntry).toHaveBeenCalledOnce() + }) + + it('arms network actions once the index fails, since no file match can arrive', () => { + fileListMock.current = { files: [], loading: false, loadError: 'scan failed' } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('example.com') + + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open URL') + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ classification: expect.objectContaining({ kind: 'host-url' }) }) + ) + }) + + it('does not auto-open a host-like filename while the file index is unresolved', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('example.com') + + expect(container.querySelector('[role="option"]')?.textContent).toContain('Open URL') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + }) + + it('disables the active submission and exposes failures to assistive technology', async () => { + let rejectOpen: ((error: Error) => void) | undefined + const onOpenEntry = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectOpen = reject + }) + ) + renderEntry({ onOpenEntry }) + setQuery('react hooks') + submit() + + // Read-only rather than disabled so the pending input keeps keyboard focus. + expect(container.querySelector('input')?.disabled).toBe(false) + expect(container.querySelector('input')?.readOnly).toBe(true) + expect(container.querySelector('input')?.getAttribute('aria-busy')).toBe('true') + expect(container.querySelector('[role="option"]')?.disabled).toBe(true) + expect(container.querySelector('[role="option"] .animate-spin')).not.toBeNull() + + await act(async () => rejectOpen?.(new Error('Search failed safely.'))) + + const input = container.querySelector('input')! + expect(input.readOnly).toBe(false) + expect(input.getAttribute('aria-busy')).toBeNull() + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(input.getAttribute('aria-errormessage')).toBe('tab-create-entry-error') + expect(container.querySelector('#tab-create-entry-error')?.textContent).toContain( + 'Search failed safely.' + ) + }) + + it('ignores completion from a previous menu session', async () => { + let resolveOpen: (() => void) | undefined + const onDidOpenEntry = vi.fn() + const onOpenEntry = vi.fn( + () => + new Promise((resolve) => { + resolveOpen = resolve + }) + ) + renderEntry({ onDidOpenEntry, onOpenEntry }) + setQuery('react hooks') + submit() + + renderEntry({ menuOpen: false, onDidOpenEntry, onOpenEntry }) + renderEntry({ menuOpen: true, onDidOpenEntry, onOpenEntry }) + await act(async () => resolveOpen?.()) + + expect(onDidOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('input')?.readOnly).toBe(false) + }) + + it('does not arm ordinary search when a ranked file disappears asynchronously', () => { + fileListMock.current = { files: ['project codename'], loading: false, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('project codename') + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open file') + + fileListMock.current = { files: [], loading: true, loadError: null } + renderEntry({ onOpenEntry }) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + expect(container.querySelector('input')?.getAttribute('aria-activedescendant')).toBeNull() + + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.') + + press('ArrowDown') + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ classification: expect.objectContaining({ kind: 'search' }) }) + ) + }) + + it('makes forced search the only category and disables open-tab matching', () => { + fileListMock.current = { files: ['gem'], loading: false, loadError: null } + tabSearchMock.results = [ + { + id: 'open-tab:workspace:tab-1', + title: 'gem tab', + contentType: 'terminal', + worktreeId: 'wt', + tabId: 'tab-1', + entityId: 'term-1', + groupId: 'g', + relativePath: null + } + ] + const menuOptions: TabCreateMenuOption[] = [ + { id: 'new-browser', kind: 'new-browser', keywords: ['gem'], label: 'Gem action' } + ] + const agentOptions: TabAgentLaunchOption[] = [ + { agent: 'gemini', aliases: ['gem'], label: 'Gemini' } + ] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ agentOptions, menuOptions, onOpenEntry }) + setQuery('?gem') + + const rows = container.querySelectorAll('[role="option"]') + expect(rows).toHaveLength(1) + expect(rows[0].textContent).toContain('Search Google') + expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' }) + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'search', engine: 'google', query: 'gem' } + }) + ) + }) + + it('treats a bare search prefix as a prompt rather than a submission error', () => { + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('?') + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + submit() + + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('#tab-create-entry-error')).toBeNull() + expect(container.querySelector('input')?.getAttribute('aria-errormessage')).toBeNull() + }) + + it('blocks oversized forced input without surfacing global actions', () => { + renderEntry({ + menuOptions: [ + { id: 'new-browser', kind: 'new-browser', keywords: ['x'], label: 'New Browser' } + ], + agentOptions: [{ agent: 'gemini', aliases: ['x'], label: 'Gemini' }] + }) + setQuery(`?${'x'.repeat(QUICK_OPEN_QUERY_MAX_BYTES)}`) + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + expect(container.textContent).toContain('Search text is too large.') + expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index fa65ce24e5f..482d81a1cf3 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -4,17 +4,10 @@ import { useRuntimeFileListForWorktree } from '../quick-open-file-list' import { createTabEntryAllowAbsolutePathsSelector, getTabEntryOptions, - isTabEntryAbsolutePathLike, - type TabCreateEntryArgs + isTabEntryAbsolutePathLike } from './tab-create-entry-action' -import { - findMatchingTabAgentLaunchOptions, - type TabAgentLaunchOption -} from './tab-agent-launch-options' -import { - findMatchingTabCreateMenuOptions, - type TabCreateMenuOption -} from './tab-create-menu-options' +import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options' +import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options' import { getActiveOptionId, isActiveEntryOption, @@ -28,42 +21,30 @@ import { } from './TabBarCreateEntryRow' import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe' import { activateOpenTabSearchResult } from './open-tab-selection-routing' -import type { OpenTabSearchResult } from './open-tab-search' import { useOpenTabSearch } from './use-open-tab-search' -import type { TuiAgent } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url' import { getRendererAppPlatform } from '@/lib/renderer-app-platform' import { useAppStore } from '@/store' +import { isQuickOpenQueryTooLarge } from '../quick-open-search' +import { parseForcedSearchQuery } from './tab-create-entry-forced-search' +import { useNetworkSafeTabEntrySelection } from './tab-create-entry-network-selection' +import { focusTabEntryMenuItemAtEdge } from './tab-create-entry-keyboard-focus' +import { + getTabEntryChooseActionMessage, + getTabEntryOmniboxPlaceholder +} from './tab-create-entry-copy' +import { + EMPTY_AGENT_OPTIONS, + EMPTY_MENU_OPTIONS, + EMPTY_TAB_RESULTS +} from './tab-create-entry-empty-options' +import type { TabBarCreateEntryProps } from './tab-create-entry-props' -// Leads with tabs: the omnibox now jumps to open tabs before it creates anything. -function omniboxPlaceholder(): string { - return translate( - 'auto.components.tab.bar.TabBarCreateEntry.0e5b7a3f16', - 'Search open tabs, files, URLs, agents…' - ) +export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element { + return } -const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = [] -const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = [] -const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = [] - -type TabBarCreateEntryProps = { - agentOptions?: readonly TabAgentLaunchOption[] - groupId: string - menuOpen: boolean - menuOptions?: readonly TabCreateMenuOption[] - onDidOpenEntry?: () => void - onLaunchAgent?: (agent: TuiAgent) => void - onOpenDefaultTerminal?: () => void - onOpenEntry?: (args: TabCreateEntryArgs) => Promise - onQueryChange?: (query: string) => void - /** Runs after the menu closes, so the tab jumped to actually takes focus. */ - onQueueSwitchFocus?: (focus: () => void) => void - onSelectMenuOption?: (option: TabCreateMenuOption) => void - worktreeId: string -} - -export default function TabBarCreateEntry({ +function TabBarCreateEntrySession({ agentOptions = EMPTY_AGENT_OPTIONS, groupId, menuOpen, @@ -81,16 +62,33 @@ export default function TabBarCreateEntry({ const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [switchError, setSwitchError] = useState(null) + const [selectionGuidance, setSelectionGuidance] = useState(null) // null = follow ranking (deferred tabs can prepend); set on arrow keys only. const [pinnedOptionId, setPinnedOptionId] = useState(null) - const [lastMenuOpen, setLastMenuOpen] = useState(menuOpen) const inputRef = useRef(null) + const submissionIdRef = useRef(0) + useEffect( + () => () => { + submissionIdRef.current += 1 + }, + [] + ) const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) - const tabSearch = useOpenTabSearch({ enabled: menuOpen, query, worktreeId }) + const rawQueryOversized = isQuickOpenQueryTooLarge(query) + const forcedSearch = parseForcedSearchQuery(query) + const terminalQueryMode = rawQueryOversized || forcedSearch.forced + const tabSearchQuery = terminalQueryMode ? '' : query + const tabSearch = useOpenTabSearch({ + enabled: menuOpen && !terminalQueryMode, + query: tabSearchQuery, + worktreeId + }) // Why gate on the query: the search defers, so its rows can still describe an // earlier query — Enter must never submit a tab the current query never matched. - const tabResults = tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS - const shouldResolveAbsolutePaths = menuOpen && isTabEntryAbsolutePathLike(query.trim()) + const tabResults = + !terminalQueryMode && tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS + const shouldResolveAbsolutePaths = + menuOpen && !terminalQueryMode && isTabEntryAbsolutePathLike(query.trim()) const allowAbsolutePathsSelector = useMemo( () => createTabEntryAllowAbsolutePathsSelector(worktreeId, { @@ -105,6 +103,9 @@ export default function TabBarCreateEntry({ menuOpen ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null ) const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix' + const searchEngine = useAppStore( + (state) => state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE + ) // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the // first item should return to the search box so the keyboard trip isn't @@ -144,14 +145,16 @@ export default function TabBarCreateEntry({ }, [menuOpen]) const matchingMenuOptions = useMemo( - () => findMatchingTabCreateMenuOptions(query, menuOptions), - [menuOptions, query] + () => + terminalQueryMode ? EMPTY_MENU_OPTIONS : findMatchingTabCreateMenuOptions(query, menuOptions), + [menuOptions, query, terminalQueryMode] ) const options = useMemo(() => { const entryOptions = dropFileEntriesCoveredByTabResults( getTabEntryOptions(query, fileList, 4, { allowAbsolutePaths, - localPlatform + localPlatform, + searchEngine }), tabResults, worktreePath @@ -167,25 +170,18 @@ export default function TabBarCreateEntry({ localPlatform, matchingMenuOptions.length, query, + searchEngine, tabResults, worktreePath ]) const matchingAgentOptions = useMemo( - () => findMatchingTabAgentLaunchOptions(query, agentOptions), - [agentOptions, query] + () => + terminalQueryMode + ? EMPTY_AGENT_OPTIONS + : findMatchingTabAgentLaunchOptions(query, agentOptions), + [agentOptions, query, terminalQueryMode] ) - if (lastMenuOpen !== menuOpen) { - setLastMenuOpen(menuOpen) - if (!menuOpen) { - setQuery('') - setPending(false) - setError(null) - setSwitchError(null) - setPinnedOptionId(null) - } - } - const disabled = !onOpenEntry const hasQuery = query.trim().length > 0 const activeOptions: ActiveOption[] = [ @@ -206,13 +202,15 @@ export default function TabBarCreateEntry({ option })) ] - // Why pin by id (not index): deferred tab rows prepend and would steal a - // user-moved highlight if we kept a raw index. Null pin follows top rank. - const pinnedOptionIndex = pinnedOptionId - ? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId) - : -1 - const activeSelectedIndex = Math.max(pinnedOptionIndex, 0) - const selectedActiveOption = activeOptions[activeSelectedIndex] + const { activeSelectedIndex, selectedActiveOption } = useNetworkSafeTabEntrySelection({ + activeOptions, + fileIndexFailed: Boolean(fileList.loadError), + fileIndexReady: !fileList.loading && !fileList.loadError, + forcedSearch: forcedSearch.forced, + menuOpen, + pinnedOptionId, + query + }) const statusOption = options.find( (option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked' ) @@ -220,7 +218,7 @@ export default function TabBarCreateEntry({ statusOption != null && (statusOption.classification.kind === 'empty' || statusOption.classification.kind === 'blocked') ? statusOption.classification.message - : omniboxPlaceholder() + : getTabEntryOmniboxPlaceholder() const submitOption = (option?: ActiveOption) => { if (disabled || pending) { @@ -233,7 +231,15 @@ export default function TabBarCreateEntry({ onDidOpenEntry?.() return } - setError(statusMessage) + if (activeOptions.length > 0) { + setSelectionGuidance(getTabEntryChooseActionMessage()) + return + } + // Why: an 'empty' status is the placeholder prompt, not a failure — showing + // it as an error turns a bare "?" into a red row that reads like a bug. + if (statusOption?.classification.kind !== 'empty') { + setError(statusMessage) + } return } if (selectedOption.kind === 'tab') { @@ -260,6 +266,7 @@ export default function TabBarCreateEntry({ } setPending(true) setError(null) + const submissionId = ++submissionIdRef.current void onOpenEntry({ query, worktreeId, @@ -268,13 +275,19 @@ export default function TabBarCreateEntry({ classification: selectedOption.option.classification }) .then(() => { - onDidOpenEntry?.() + if (submissionIdRef.current === submissionId) { + onDidOpenEntry?.() + } }) .catch((caught) => { - setError(caught instanceof Error ? caught.message : String(caught)) + if (submissionIdRef.current === submissionId) { + setError(caught instanceof Error ? caught.message : String(caught)) + } }) .finally(() => { - setPending(false) + if (submissionIdRef.current === submissionId) { + setPending(false) + } }) } @@ -292,15 +305,23 @@ export default function TabBarCreateEntry({ event.stopPropagation() const delta = event.key === 'ArrowDown' ? 1 : -1 const nextIndex = - (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length + activeSelectedIndex === null + ? event.key === 'ArrowDown' + ? 0 + : activeOptions.length - 1 + : (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length setPinnedOptionId(getActiveOptionId(activeOptions[nextIndex])) + setSelectionGuidance(null) return } // Why: with no result rows the static create/agent items render below; // move focus into that Radix menu list so it stays keyboard-navigable // from the search box instead of trapping focus in the input. if ( - focusMenuItemAtEdge(event.currentTarget, event.key === 'ArrowDown' ? 'first' : 'last') + focusTabEntryMenuItemAtEdge( + event.currentTarget, + event.key === 'ArrowDown' ? 'first' : 'last' + ) ) { event.preventDefault() event.stopPropagation() @@ -326,18 +347,26 @@ export default function TabBarCreateEntry({ setPinnedOptionId(null) setError(null) setSwitchError(null) + setSelectionGuidance(null) }} + // Why: disabling the focused input would hand focus to the body and + // break arrow/Escape navigation until the submission settles. disabled={disabled} + readOnly={pending} + aria-busy={pending || undefined} role="combobox" - aria-expanded={activeOptions.length > 0} - aria-controls={RESULT_LISTBOX_ID} + aria-expanded={!error && activeOptions.length > 0} + aria-controls={!error && activeOptions.length > 0 ? RESULT_LISTBOX_ID : undefined} aria-autocomplete="list" aria-activedescendant={ - activeOptions.length > 0 && !error ? resultOptionDomId(activeSelectedIndex) : undefined + activeSelectedIndex !== null && !error + ? resultOptionDomId(activeSelectedIndex) + : undefined } - aria-label={omniboxPlaceholder()} + aria-label={getTabEntryOmniboxPlaceholder()} aria-invalid={error ? true : undefined} - placeholder={omniboxPlaceholder()} + aria-errormessage={error ? 'tab-create-entry-error' : undefined} + placeholder={getTabEntryOmniboxPlaceholder()} className="h-9 rounded-none border-0 bg-transparent px-0 text-xs font-normal text-foreground shadow-none placeholder:font-normal placeholder:text-muted-foreground focus-visible:border-0 focus-visible:ring-0 aria-invalid:border-0 aria-invalid:ring-0 md:text-xs dark:bg-transparent" /> @@ -345,28 +374,46 @@ export default function TabBarCreateEntry({ the rows the user can still act on. The live region stays mounted so a screen reader announces the failure instead of missing the insertion. */}
+ {error ? ( +
+ +
+ ) : null} {switchError ? (
) : null} + {selectionGuidance ? ( +
+ +
+ ) : null} + {activeOptions.length > 0 && statusOption ? ( +
+ +
+ ) : null}
- {error || activeOptions.length > 0 || hasQuery ? ( + {!error && (activeOptions.length > 0 || hasQuery) ? (
0 && !error ? 'listbox' : undefined} + role={activeOptions.length > 0 ? 'listbox' : undefined} > - {error ? ( - - ) : activeOptions.length > 0 ? ( + {activeOptions.length > 0 ? ( activeOptions.map((option, index) => ( submitOption(option)} + disabled={disabled || pending} + loading={pending && index === activeSelectedIndex} + onClick={() => { + setSelectionGuidance(null) + submitOption(option) + }} /> )) ) : ( @@ -377,21 +424,3 @@ export default function TabBarCreateEntry({ ) } - -// Moves keyboard focus to the first/last enabled item of the enclosing Radix -// menu so the static create/agent list stays navigable from the search input. -function focusMenuItemAtEdge(fromElement: HTMLElement, edge: 'first' | 'last'): boolean { - const menu = fromElement.closest('[role="menu"]') - if (!menu) { - return false - } - const items = menu.querySelectorAll( - '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' - ) - const target = edge === 'first' ? items[0] : items.item(items.length - 1) - if (!target) { - return false - } - target.focus() - return true -} diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx index 616b3caa0c6..eafad8ad790 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx @@ -2,6 +2,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { getByRole } from '@testing-library/dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { ActiveOption } from './tab-create-entry-active-option' @@ -88,4 +89,18 @@ describe('EntryActionRow', () => { expect(renderRow(makeFileOption('README.md')).textContent).toContain('README.md') expect(container.textContent).not.toContain('/') }) + + it('names the provider and query without retaining a built URL', () => { + renderRow({ + kind: 'entry', + option: { + id: 'search:private project', + classification: { kind: 'search', engine: 'kagi', query: 'private project' } + } + }) + + expect(getByRole(container, 'option', { name: 'Search Kagi private project' })).toBeTruthy() + expect(container.textContent).toBe('Search Kagi·private project') + expect(container.textContent).not.toContain('https://') + }) }) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 0cca7d4b8c6..5e99818009d 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -5,6 +5,7 @@ import { GitCompare, Globe, Loader2, + Search, Smartphone, TerminalSquare } from 'lucide-react' @@ -12,6 +13,7 @@ import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' +import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' @@ -38,12 +40,16 @@ export function EntryStatusRow({ } export function EntryActionRow({ + disabled = false, id, + loading = false, onClick, option, selected }: { + disabled?: boolean id: string + loading?: boolean onClick: () => void option: ActiveOption selected: boolean @@ -56,15 +62,22 @@ export function EntryActionRow({ id={id} role="option" aria-selected={selected} + disabled={disabled} className={cn( - 'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none', + 'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none disabled:cursor-not-allowed disabled:opacity-50', selected ? 'bg-black/8 text-accent-foreground dark:bg-white/14' - : 'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground dark:hover:bg-white/14' + : // Why: CSS :hover still matches a disabled button, so a pending row would + // light up under the pointer despite being unactivatable. + 'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground disabled:hover:bg-transparent disabled:hover:text-muted-foreground dark:hover:bg-white/14 dark:disabled:hover:bg-transparent' )} onClick={onClick} > - {presentation.icon} + {loading ? ( +
) : ( -
+
+
@@ -91,7 +91,7 @@ const TABLE_ROW_SKELETONS = [ export function AutomationsPageSkeleton(): React.JSX.Element { return (
Date: Tue, 11 Aug 2026 17:38:15 -0700 Subject: [PATCH 013/109] Resolve threads as primary acknowledgement, not replies (#13894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsed threads visibly close on the host — this is the acknowledgement. An extra reply only adds noise. Snapshot the resolution target at launch to prevent silent drops if the panel navigates before agent delivery. Only reply for comments without resolution endpoints. --- .../pr-comments-resolution-prompt.ts | 2 +- .../components/right-sidebar/ChecksPanel.tsx | 99 ++-- ...omment-snapshotted-thread-resolver.test.ts | 178 +++++++ .../pr-comment-snapshotted-thread-resolver.ts | 89 ++++ .../pr-comments-ai-launch-ack.test.ts | 467 +++++++++--------- .../pr-comments-ai-launch-ack.ts | 107 ++-- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- 11 files changed, 649 insertions(+), 316 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.ts b/src/renderer/src/components/pr-comments-resolution-prompt.ts index a31d4340844..d86a4e8d653 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.ts @@ -150,7 +150,7 @@ export function buildPRCommentsResolutionPrompt({ '- For outdated comments, inspect the current file and nearby code before editing. Apply the reviewer intent only if it still matches the current code.', '- Keep changes minimal and coherent. If multiple selected comments conflict or require a larger design decision, stop and report the tradeoff instead of guessing.', '- Preserve unrelated staged and unstaged work. Do not run destructive cleanup commands such as git reset --hard, git checkout ., git restore ., or git stash.', - '- Host thread resolution and any fixing replies are handled by Orca after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.', + '- Orca acknowledges this feedback on the host itself after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.', '- Do not push, create commits, or rewrite history.', '- Run git diff --check before finishing. Run the most focused relevant tests, typecheck, or lint command you can reasonably identify; if validation is impractical, explain why.', '', diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index c109c46b1a3..25489863d7f 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -79,17 +79,16 @@ import { getBrokenChecks, getCheckDetailsPromptKey } from '../pr-checks-fix-prompt' -import { - buildPRCommentsResolutionPrompt, - isResolvablePRCommentGroup -} from '../pr-comments-resolution-prompt' +import { buildPRCommentsResolutionPrompt } from '../pr-comments-resolution-prompt' import { buildPRCommentConversationReplyBody } from './pr-comment-fixing-reply-body' +import { buildSnapshottedThreadResolver } from './pr-comment-snapshotted-thread-resolver' import { acknowledgePRCommentsAfterAiLaunch, attachPRReviewReplyParent, canPostPRReviewThreadReply, checksPanelReviewStableKey, clearPendingPRCommentAiAck, + hasPRCommentGroupNeedingReply, resolvePRReviewReplyThreadId, setPendingPRCommentAiAck, takePendingPRCommentAiAck @@ -195,7 +194,7 @@ import { formatCreateError } from './create-pull-request-review-copy' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { translate } from '@/i18n/i18n' -import { groupPRComments, type PRCommentGroup } from '../../../../shared/pr-comment-groups' +import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { openChecksPanelHostedReviewUrl, resolveChecksPanelHostedReviewModifierDestination, @@ -3126,9 +3125,6 @@ export default function ChecksPanel(): React.JSX.Element { if (commentResolutionAckBusyRef.current) { return } - const selectedThreadIds = selectedGroups.flatMap((group) => - group.kind === 'thread' && isResolvablePRCommentGroup(group) ? [group.threadId] : [] - ) if (selectedGroups.length === 0) { toast.message( translate( @@ -3168,12 +3164,26 @@ export default function ChecksPanel(): React.JSX.Element { } })() const githubTarget = githubTargetFromPr ?? githubTargetFromUrl + // Why: resolving needs no prRepo, so a degraded PR entry that only yields a number must + // still ack by resolving instead of failing every selected thread. + const githubResolveTarget = + githubTarget ?? + (activeReview.provider === 'github' && prNumber + ? { repoPath: repo.path, repoId: repo.id, prNumber } + : undefined) + // Why: the ack resolves against this MR after delivery, so pin the iid now — the panel + // may already be showing a different review by then. + const gitlabTarget = + activeReview.provider === 'gitlab' + ? { repoPath: repo.path, repoId: repo.id, iid: activeReview.number } + : undefined const commentResolution = { reviewContextKey: stateRequestKey, provider: activeReview.provider, - selectedThreadIds, selectedGroups, - githubTarget + githubTarget, + githubResolveTarget, + gitlabTarget } claimedCommentResolutionRef.current = null commentResolutionLaunchAcceptedRef.current = false @@ -3185,13 +3195,13 @@ export default function ChecksPanel(): React.JSX.Element { { value0: activeReview.provider === 'gitlab' ? 'MR' : 'PR' } ), // Why: only GitHub with a resolved PR target posts fixing replies; other providers - // must not be promised a reply the ack never sends. Review threads get one reply - // each, conversation comments share one — so the copy stays deliberately vague. + // must not be promised a reply the ack never sends. Resolvable threads are acked by + // resolving alone, so the copy leads with that. description: githubTarget && activeReview.provider === 'github' ? translate( - 'auto.components.right.sidebar.ChecksPanel.ed3f79c031', - 'Review the prompt before starting an agent. After the prompt is delivered, Orca replies to the selected comments and resolves host threads when possible.' + 'auto.components.right.sidebar.ChecksPanel.5eb2163b6b', + 'Review the prompt before starting an agent. After the prompt is delivered, Orca resolves the selected host threads and replies to comments it cannot resolve.' ) : translate( 'auto.components.right.sidebar.ChecksPanel.abf59262fb', @@ -3257,25 +3267,42 @@ export default function ChecksPanel(): React.JSX.Element { checksPanelReviewStableKey(asyncResultKeyRef.current) === launchStableKey const githubTarget = resolution.githubTarget const canReplyOnHost = resolution.provider === 'github' && githubTarget != null - // Why: only GitHub posts fixing replies today; a GitLab MR reaching replied=0 is expected, not an error. - let lastReplyError = - resolution.provider === 'github' && githubTarget == null + // Why: only GitHub posts fixing replies today; a GitLab MR reaching replied=0 is expected, + // and a missing reply target only matters when something in the selection needs a reply. + let lastHostError = + resolution.provider === 'github' && + githubTarget == null && + hasPRCommentGroupNeedingReply(resolution.selectedGroups) ? translate( 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', 'Could not resolve the GitHub PR to reply on.' ) : undefined + const resolveSnapshottedThread = buildSnapshottedThreadResolver({ + provider: resolution.provider, + githubResolveTarget: resolution.githubResolveTarget, + gitlabTarget: resolution.gitlabTarget, + resolveReviewThread, + resolveGitLabDiscussion: (args) => + resolveGitLabMRDiscussionForChecks({ ...args, settings }), + isPanelStillOnLaunchReview, + onResolvedOptimistically: (threadId) => { + setComments((prev) => markPRCommentThreadResolved(prev, threadId, true)) + }, + onResolveFailed: ({ threadId, error }) => { + lastHostError = + error || + translate( + 'auto.components.right.sidebar.ChecksPanel.430f1a62d4', + 'Could not resolve the selected thread on the host.' + ) + console.warn('Post-launch thread resolve failed:', threadId, error) + } + }) const counts = await acknowledgePRCommentsAfterAiLaunch({ groups: resolution.selectedGroups, deps: { - isStillCurrent: isPanelStillOnLaunchReview, - isThreadStillResolvable: (threadId) => { - const currentGroup = groupPRComments(commentsRef.current).find( - (group) => group.kind === 'thread' && group.threadId === threadId - ) - return Boolean(currentGroup && isResolvablePRCommentGroup(currentGroup)) - }, - resolveThread: (threadId) => handleResolve(threadId, true, { notifyOnFailure: false }), + resolveThread: resolveSnapshottedThread, canReply: canReplyOnHost, replyInThread: async (comment, body) => { if (!githubTarget || !canPostPRReviewThreadReply(comment)) { @@ -3316,11 +3343,11 @@ export default function ChecksPanel(): React.JSX.Element { } return true } - lastReplyError = result.error + lastHostError = result.error console.warn('In-thread fixing reply failed:', result.error) return false } catch (err) { - lastReplyError = err instanceof Error ? err.message : String(err) + lastHostError = err instanceof Error ? err.message : String(err) console.warn('Failed to post in-thread fixing reply for review comment:', err) return false } @@ -3347,11 +3374,11 @@ export default function ChecksPanel(): React.JSX.Element { } return true } - lastReplyError = result.error + lastHostError = result.error console.warn('Conversation fixing reply failed:', result.error) return false } catch (err) { - lastReplyError = err instanceof Error ? err.message : String(err) + lastHostError = err instanceof Error ? err.message : String(err) console.warn('Failed to post conversation fixing reply for review comment:', err) return false } @@ -3364,9 +3391,12 @@ export default function ChecksPanel(): React.JSX.Element { } // Why: surface the underlying API error when replies were possible but none landed. + // Resolvable threads are acked by resolving, so replied=0 is correct when nothing needed one. const repliedNoneDespiteHostSupport = - canReplyOnHost && counts.replied === 0 && resolution.selectedGroups.length > 0 - if (counts.failed > 0 || repliedNoneDespiteHostSupport || lastReplyError) { + canReplyOnHost && + counts.replied === 0 && + hasPRCommentGroupNeedingReply(resolution.selectedGroups) + if (counts.failed > 0 || repliedNoneDespiteHostSupport || lastHostError) { toast.error( translate( 'auto.components.right.sidebar.ChecksPanel.f273f2271c', @@ -3376,7 +3406,7 @@ export default function ChecksPanel(): React.JSX.Element { value1: counts.replied, value2: counts.skipped, value3: counts.failed, - value4: lastReplyError ? ` ${lastReplyError}` : '' + value4: lastHostError ? ` ${lastHostError}` : '' } ) ) @@ -3399,8 +3429,9 @@ export default function ChecksPanel(): React.JSX.Element { addPRConversationComment, addPRReviewCommentReply, clearSentCommentSelection, - handleResolve, - refreshCommentsAfterBulkResolve + refreshCommentsAfterBulkResolve, + resolveReviewThread, + settings ] ) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts new file mode 100644 index 00000000000..ea4b5936ed6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildSnapshottedThreadResolver, + type SnapshottedThreadResolverDeps +} from './pr-comment-snapshotted-thread-resolver' + +const PR_REPO = { owner: 'acme', repo: 'widgets', host: 'github.com' } + +function deps( + overrides: Partial = {} +): SnapshottedThreadResolverDeps { + return { + provider: 'github', + githubResolveTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 }, + resolveReviewThread: vi.fn().mockResolvedValue(true), + resolveGitLabDiscussion: vi.fn().mockResolvedValue({ ok: true }), + isPanelStillOnLaunchReview: () => true, + onResolvedOptimistically: vi.fn(), + onResolveFailed: vi.fn(), + ...overrides + } +} + +describe('buildSnapshottedThreadResolver', () => { + it('resolves a GitHub thread against the snapshotted PR, passing prRepo when known', async () => { + const resolveReviewThread = vi.fn().mockResolvedValue(true) + const onResolvedOptimistically = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + githubResolveTarget: { + repoPath: '/repos/widgets', + repoId: 'repo-1', + prNumber: 42, + prRepo: PR_REPO + }, + resolveReviewThread, + onResolvedOptimistically + }) + ) + + expect(await resolve('T1')).toBe(true) + expect(resolveReviewThread).toHaveBeenCalledWith('/repos/widgets', 42, 'T1', true, { + repoId: 'repo-1', + prRepo: PR_REPO + }) + expect(onResolvedOptimistically).toHaveBeenCalledWith('T1') + }) + + // Why: resolveReviewThread takes prRepo as an option; a degraded PR entry must still resolve. + it('resolves a GitHub thread when the snapshot has no prRepo', async () => { + const resolveReviewThread = vi.fn().mockResolvedValue(true) + const resolve = buildSnapshottedThreadResolver(deps({ resolveReviewThread })) + + expect(await resolve('T1')).toBe(true) + expect(resolveReviewThread).toHaveBeenCalledWith('/repos/widgets', 42, 'T1', true, { + repoId: 'repo-1', + prRepo: undefined + }) + }) + + it('resolves a GitLab discussion against the snapshotted MR iid', async () => { + const resolveGitLabDiscussion = vi.fn().mockResolvedValue({ ok: true }) + const resolveReviewThread = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + provider: 'gitlab', + githubResolveTarget: undefined, + gitlabTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', iid: 7 }, + resolveGitLabDiscussion, + resolveReviewThread + }) + ) + + expect(await resolve('D1')).toBe(true) + expect(resolveGitLabDiscussion).toHaveBeenCalledWith({ + repoPath: '/repos/widgets', + repoId: 'repo-1', + iid: 7, + discussionId: 'D1', + resolved: true + }) + expect(resolveReviewThread).not.toHaveBeenCalled() + }) + + it('fails without calling a host when the provider cannot resolve threads', async () => { + const resolveReviewThread = vi.fn() + const resolveGitLabDiscussion = vi.fn() + const onResolveFailed = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + provider: 'bitbucket', + githubResolveTarget: undefined, + resolveReviewThread, + resolveGitLabDiscussion, + onResolveFailed + }) + ) + + expect(await resolve('T1')).toBe(false) + expect(resolveReviewThread).not.toHaveBeenCalled() + expect(resolveGitLabDiscussion).not.toHaveBeenCalled() + expect(onResolveFailed).toHaveBeenCalledWith({ threadId: 'T1', error: undefined }) + }) + + it('fails when the snapshotted target is missing', async () => { + const resolveGithub = buildSnapshottedThreadResolver(deps({ githubResolveTarget: undefined })) + const resolveGitlab = buildSnapshottedThreadResolver( + deps({ provider: 'gitlab', githubResolveTarget: undefined, gitlabTarget: undefined }) + ) + + expect(await resolveGithub('T1')).toBe(false) + expect(await resolveGitlab('D1')).toBe(false) + }) + + it('surfaces the GitLab host error instead of swallowing it', async () => { + const onResolveFailed = vi.fn() + const onResolvedOptimistically = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + provider: 'gitlab', + githubResolveTarget: undefined, + gitlabTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', iid: 7 }, + resolveGitLabDiscussion: vi.fn().mockResolvedValue({ ok: false, error: 'timed out' }), + onResolveFailed, + onResolvedOptimistically + }) + ) + + expect(await resolve('D1')).toBe(false) + expect(onResolveFailed).toHaveBeenCalledWith({ threadId: 'D1', error: 'timed out' }) + expect(onResolvedOptimistically).not.toHaveBeenCalled() + }) + + // Why: one rejected host call must not abort the bulk ack pool. + it('reports a rejected host call as a failure', async () => { + const onResolveFailed = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + resolveReviewThread: vi.fn().mockRejectedValue(new Error('socket closed')), + onResolveFailed + }) + ) + + expect(await resolve('T1')).toBe(false) + expect(onResolveFailed).toHaveBeenCalledWith({ threadId: 'T1', error: 'socket closed' }) + }) + + it('skips the optimistic update when the resolve failed', async () => { + const onResolvedOptimistically = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + resolveReviewThread: vi.fn().mockResolvedValue(false), + onResolvedOptimistically + }) + ) + + expect(await resolve('T1')).toBe(false) + expect(onResolvedOptimistically).not.toHaveBeenCalled() + }) + + // Why: the host call still belongs to the launch review, but the optimistic row would land + // in whatever review the panel navigated to. + it('still resolves on the host but skips the optimistic update after the panel moved', async () => { + const resolveReviewThread = vi.fn().mockResolvedValue(true) + const onResolvedOptimistically = vi.fn() + const resolve = buildSnapshottedThreadResolver( + deps({ + resolveReviewThread, + isPanelStillOnLaunchReview: () => false, + onResolvedOptimistically + }) + ) + + expect(await resolve('T1')).toBe(true) + expect(resolveReviewThread).toHaveBeenCalledTimes(1) + expect(onResolvedOptimistically).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts new file mode 100644 index 00000000000..f5fddeb05b2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.ts @@ -0,0 +1,89 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { + PendingPRCommentAiAckGithubResolveTarget, + PendingPRCommentAiAckGitlabTarget +} from './pr-comments-ai-launch-ack' + +/** Host outcome shape; GitLab reports a message, GitHub only reports success. */ +export type SnapshottedThreadResolveOutcome = { ok: boolean; error?: string } + +export type SnapshottedThreadResolverDeps = { + provider: HostedReviewInfo['provider'] + githubResolveTarget?: PendingPRCommentAiAckGithubResolveTarget + gitlabTarget?: PendingPRCommentAiAckGitlabTarget + resolveReviewThread: ( + repoPath: string, + prNumber: number, + threadId: string, + resolve: boolean, + options: { + repoId: string + prRepo?: PendingPRCommentAiAckGithubResolveTarget['prRepo'] + } + ) => Promise + resolveGitLabDiscussion: (args: { + repoPath: string + repoId: string + iid: number + discussionId: string + resolved: boolean + }) => Promise + /** False once the panel moved to another review: host calls continue, UI writes stop. */ + isPanelStillOnLaunchReview: () => boolean + onResolvedOptimistically: (threadId: string) => void + onResolveFailed: (args: { threadId: string; error?: string }) => void +} + +/** + * Resolve one host thread against the review snapshotted at launch: the panel can be showing a + * different PR/MR by the time the agent prompt lands, so live state must not pick the target. + */ +export function buildSnapshottedThreadResolver( + deps: SnapshottedThreadResolverDeps +): (threadId: string) => Promise { + return async (threadId: string): Promise => { + const outcome = await resolveOnHost(deps, threadId) + if (!outcome.ok) { + deps.onResolveFailed({ threadId, error: outcome.error }) + return false + } + // Why: the optimistic row must never land in another review's list; the caller's guarded + // refresh reconciles whichever panel is actually showing. + if (deps.isPanelStillOnLaunchReview()) { + deps.onResolvedOptimistically(threadId) + } + return true + } +} + +async function resolveOnHost( + deps: SnapshottedThreadResolverDeps, + threadId: string +): Promise { + try { + if (deps.provider === 'gitlab') { + if (!deps.gitlabTarget) { + return { ok: false } + } + return await deps.resolveGitLabDiscussion({ + repoPath: deps.gitlabTarget.repoPath, + repoId: deps.gitlabTarget.repoId, + iid: deps.gitlabTarget.iid, + discussionId: threadId, + resolved: true + }) + } + if (deps.provider !== 'github' || !deps.githubResolveTarget) { + return { ok: false } + } + const target = deps.githubResolveTarget + const ok = await deps.resolveReviewThread(target.repoPath, target.prNumber, threadId, true, { + repoId: target.repoId, + prRepo: target.prRepo + }) + return { ok } + } catch (err) { + // Why: one rejected host call would otherwise abort the whole bulk ack pool. + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts index dec478b8e1f..3ae9663f314 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts @@ -8,6 +8,8 @@ import { checksPanelReviewStableKey, clearPendingPRCommentAiAck, getPRCommentGroupReplyTarget, + getPRCommentGroupsNeedingReply, + hasPRCommentGroupNeedingReply, PR_COMMENT_ACK_MAX_CONCURRENCY, resolvePRReviewReplyThreadId, setPendingPRCommentAiAck, @@ -290,17 +292,16 @@ describe('checksPanelReviewStableKey', () => { }) describe('acknowledgePRCommentsAfterAiLaunch', () => { - it('replies in-thread and resolves host-resolvable threads', async () => { + // Why: the reported bug — resolving is the acknowledgement, so a resolvable thread must + // never also get a "Fixing." reply on top of a thread that visibly collapsed. + it('resolves host-resolvable threads without replying to them', async () => { const resolveThread = vi.fn().mockResolvedValue(true) const replyInThread = vi.fn().mockResolvedValue(true) const replyAsConversation = vi.fn().mockResolvedValue(true) - const group = openThread('T1') const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [group, openThread('T2', 11)], + groups: [openThread('T1'), openThread('T2', 11)], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, resolveThread, canReply: true, replyInThread, @@ -308,24 +309,19 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { } }) - expect(result).toEqual({ resolved: 2, replied: 2, skipped: 0, failed: 0 }) - expect(replyInThread).toHaveBeenCalledTimes(2) - expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + expect(result).toEqual({ resolved: 2, replied: 0, skipped: 0, failed: 0 }) + expect(resolveThread.mock.calls.map(([threadId]) => threadId)).toEqual(['T1', 'T2']) + expect(replyInThread).not.toHaveBeenCalled() expect(replyAsConversation).not.toHaveBeenCalled() - expect(resolveThread).toHaveBeenCalledTimes(2) }) - // Why: on GitHub canReply is true; a failed resolve still leaves the thread open and - // must count as failed or the success toast lies. - it('still replies in-thread when resolve fails, and counts the failed resolve', async () => { - const replyInThread = vi.fn().mockResolvedValue(true) - const group = openThread('T1') + // Why: a failed resolve leaves the thread open on the host, so the toast must not claim success. + it('counts a failed resolve instead of falling back to a reply', async () => { + const replyInThread = vi.fn() const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [group], + groups: [openThread('T1')], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, resolveThread: vi.fn().mockResolvedValue(false), canReply: true, replyInThread, @@ -333,19 +329,161 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { } }) - expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 1 }) - expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + expect(replyInThread).not.toHaveBeenCalled() }) - it('posts a conversation @-reply for standalone conversation comments', async () => { - const replyAsConversation = vi.fn().mockResolvedValue(true) - const group = standalone(42) + // Why: GitLab reaches the ack with canReply false, so a failed resolve has no reply to fall + // back on and must still be counted. + it('counts a failed resolve on a provider that cannot reply', async () => { + const replyInThread = vi.fn() + const replyAsConversation = vi.fn() + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1')], + deps: { + resolveThread: vi.fn().mockResolvedValue(false), + canReply: false, + replyInThread, + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + expect(replyInThread).not.toHaveBeenCalled() + expect(replyAsConversation).not.toHaveBeenCalled() + }) + + // Why: resolve used to be gated on panel/live-comment state and got silently skipped when + // a slow agent launch outlived either; the snapshotted threadId must always be attempted. + it('resolves every selected thread with no live-state gate', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1'), openThread('T2', 11), openThread('T3', 12)], + deps: { + resolveThread, + canReply: true, + replyInThread: vi.fn(), + replyAsConversation: vi.fn() + } + }) + + expect(resolveThread).toHaveBeenCalledTimes(3) + expect(result).toEqual({ resolved: 3, replied: 0, skipped: 0, failed: 0 }) + }) + + // Why: GitLab MRs reach the ack with canReply false; resolve is the whole acknowledgement there. + it('resolves threads on providers that cannot reply', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1')], + deps: { + resolveThread, + canReply: false, + replyInThread: vi.fn(), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 1, replied: 0, skipped: 0, failed: 0 }) + expect(resolveThread).toHaveBeenCalledWith('T1') + }) + + // Why: a summary thread GitHub *can* resolve is acked by resolving, even though its + // pullrequestreview anchor rules out a nested reply. + it('resolves a review-summary thread instead of batching it into a conversation post', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) + const replyAsConversation = vi.fn() + const summaryThread: PRCommentGroup = { + kind: 'thread', + threadId: 'T_summary', + root: comment({ + id: 30, + threadId: 'T_summary', + isResolved: false, + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }), + replies: [] + } + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [summaryThread], + deps: { + resolveThread, + canReply: true, + replyInThread: vi.fn(), + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 1, replied: 0, skipped: 0, failed: 0 }) + expect(resolveThread).toHaveBeenCalledWith('T_summary') + expect(replyAsConversation).not.toHaveBeenCalled() + }) + + // Why: an already-resolved thread has nothing left to close, so it falls back to a reply. + it('replies in-thread when the thread is already resolved', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const resolveThread = vi.fn() + const group: PRCommentGroup = { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'a.ts', isResolved: true }), + replies: [] + } const result = await acknowledgePRCommentsAfterAiLaunch({ groups: [group], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(resolveThread).not.toHaveBeenCalled() + expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + }) + + // Why: GitHub's replies endpoint keys off the top-level review comment id; replying to the + // newest reply 404s. + it('replies to the thread root, not the newest reply, on an already-resolved thread', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const root = comment({ id: 10, threadId: 'T1', path: 'a.ts', isResolved: true }) + const group: PRCommentGroup = { + kind: 'thread', + threadId: 'T1', + root, + replies: [ + comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'first' }), + comment({ id: 12, threadId: 'T1', path: 'a.ts', body: 'latest' }) + ] + } + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group], + deps: { + resolveThread: vi.fn(), + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledWith(root, PR_COMMENT_AI_FIXING_REPLY) + }) + + it('posts a conversation @-reply for standalone conversation comments', async () => { + const replyAsConversation = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [standalone(42)], + deps: { resolveThread: vi.fn(), canReply: true, replyInThread: vi.fn(), @@ -364,8 +502,6 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { await acknowledgePRCommentsAfterAiLaunch({ groups: [openThread('T1'), openThread('T2', 11)], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, resolveThread: vi.fn().mockResolvedValue(true), canReply: true, replyInThread: vi.fn().mockResolvedValue(true), @@ -379,13 +515,10 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { it('posts a conversation @-reply for CodeRabbit review summaries', async () => { const replyInThread = vi.fn().mockResolvedValue(true) const replyAsConversation = vi.fn().mockResolvedValue(true) - const group = codeRabbitReviewSummary(77) const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [group], + groups: [codeRabbitReviewSummary(77)], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, resolveThread: vi.fn(), canReply: true, replyInThread, @@ -398,8 +531,8 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { expect(replyAsConversation).toHaveBeenCalledWith(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) }) - // Why: the reported bug — N selected summaries used to produce N near-identical - // "Fixing." conversation comments; one combined post covers the whole set. + // Why: N selected summaries used to produce N near-identical "Fixing." conversation + // comments; one combined post covers the whole set. it('combines every unresolvable review summary into one conversation comment', async () => { const replyAsConversation = vi.fn().mockResolvedValue(true) const resolveThread = vi.fn() @@ -407,8 +540,6 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { const result = await acknowledgePRCommentsAfterAiLaunch({ groups: [codeRabbitReviewSummary(30), codeRabbitReviewSummary(31), standalone(32)], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => false, resolveThread, canReply: true, replyInThread: vi.fn(), @@ -432,31 +563,8 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { expect(resolveThread).not.toHaveBeenCalled() }) - it('mixes in-thread and conversation replies in a single selection', async () => { - const replyInThread = vi.fn().mockResolvedValue(true) - const replyAsConversation = vi.fn().mockResolvedValue(true) - const thread = openThread('T1', 10) - const summary = codeRabbitReviewSummary(30) - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [thread, summary], - deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread: vi.fn().mockResolvedValue(true), - canReply: true, - replyInThread, - replyAsConversation - } - }) - - expect(result).toEqual({ resolved: 1, replied: 2, skipped: 0, failed: 0 }) - expect(replyInThread).toHaveBeenCalledWith(thread.root, PR_COMMENT_AI_FIXING_REPLY) - expect(replyAsConversation).toHaveBeenCalledWith(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) - }) - - // Why: nestable threads keep one reply each; only the conversation half is batched. - it('replies once per thread and once for the whole conversation set', async () => { + it('resolves threads and batches the conversation half in a single selection', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) const replyInThread = vi.fn().mockResolvedValue(true) const replyAsConversation = vi.fn().mockResolvedValue(true) @@ -468,27 +576,42 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { standalone(31) ], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread: vi.fn().mockResolvedValue(true), + resolveThread, canReply: true, replyInThread, replyAsConversation } }) - expect(result).toEqual({ resolved: 2, replied: 3, skipped: 0, failed: 0 }) - expect(replyInThread).toHaveBeenCalledTimes(2) + expect(result).toEqual({ resolved: 2, replied: 1, skipped: 0, failed: 0 }) + expect(replyInThread).not.toHaveBeenCalled() expect(replyAsConversation).toHaveBeenCalledTimes(1) expect(replyAsConversation.mock.calls[0]?.[0]).toContain('Fixing:') }) + // Why: a path-only review comment with no thread metadata still nests a reply. + it('replies in-thread for an unresolvable comment that supports nesting', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const target = comment({ id: 10, path: 'src/a.ts', line: 12 }) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [{ kind: 'standalone', comment: target }], + deps: { + resolveThread: vi.fn(), + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledWith(target, PR_COMMENT_AI_FIXING_REPLY) + }) + it('counts a failed conversation reply instead of silently reporting success', async () => { const result = await acknowledgePRCommentsAfterAiLaunch({ groups: [codeRabbitReviewSummary(30)], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => false, resolveThread: vi.fn(), canReply: true, replyInThread: vi.fn(), @@ -499,37 +622,10 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) }) - it('replies to the thread root in a multi-comment thread', async () => { - const replyInThread = vi.fn().mockResolvedValue(true) - const group: PRCommentGroup = { - kind: 'thread', - threadId: 'T1', - root: comment({ id: 10, threadId: 'T1', path: 'a.ts', isResolved: false }), - replies: [comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'prior reply' })] - } - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [group], - deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => false, - resolveThread: vi.fn(), - canReply: true, - replyInThread, - replyAsConversation: vi.fn() - } - }) - - expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) - expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) - }) - - it('skips when reply is unavailable and resolve cannot run', async () => { + it('skips an unresolvable comment when the provider cannot reply', async () => { const result = await acknowledgePRCommentsAfterAiLaunch({ groups: [standalone()], deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, resolveThread: vi.fn(), canReply: false, replyInThread: vi.fn(), @@ -540,27 +636,10 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { expect(result).toEqual({ resolved: 0, replied: 0, skipped: 1, failed: 0 }) }) - it('counts a failure when resolve fails and reply is unavailable', async () => { - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [openThread('T1')], - deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread: vi.fn().mockResolvedValue(false), - canReply: false, - replyInThread: vi.fn(), - replyAsConversation: vi.fn() - } - }) - - expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) - }) - it('keeps a durable pending payload across take', () => { const payload: PendingPRCommentAiAck = { reviewContextKey: 'repo::main::owner/repo::12::abc', provider: 'github', - selectedThreadIds: ['T1'], selectedGroups: [openThread('T1')] } clearPendingPRCommentAiAck() @@ -569,136 +648,78 @@ describe('acknowledgePRCommentsAfterAiLaunch', () => { expect(takePendingPRCommentAiAck()).toBeNull() }) - it('still posts all replies when the review context becomes stale mid-loop', async () => { - let live = true - const replyInThread = vi.fn().mockImplementation(async () => { - live = false - return true - }) - const resolveThread = vi.fn().mockResolvedValue(true) - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [openThread('T1'), openThread('T2', 11), openThread('T3', 12)], - deps: { - isStillCurrent: () => live, - isThreadStillResolvable: () => true, - resolveThread, - canReply: true, - replyInThread, - replyAsConversation: vi.fn() - } - }) - - // Why: replies must not depend on checks-panel key stability; resolve stops after context - // goes stale, and each skipped resolve is reported rather than silently counted as success. - expect(replyInThread).toHaveBeenCalledTimes(3) - expect(resolveThread).toHaveBeenCalledTimes(0) - expect(result).toEqual({ resolved: 0, replied: 3, skipped: 3, failed: 0 }) - }) - - // Why: resolving after a failed reply closes the thread with no ack at all. - it('leaves a thread open when its fixing reply failed', async () => { - const resolveThread = vi.fn().mockResolvedValue(true) - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [openThread('T1')], - deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread, - canReply: true, - replyInThread: vi.fn().mockResolvedValue(false), - replyAsConversation: vi.fn() - } - }) - - expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) - expect(resolveThread).not.toHaveBeenCalled() - }) - - it('does not resolve batched conversation groups when the combined post failed', async () => { - const resolveThread = vi.fn() - const summaryThread: PRCommentGroup = { - kind: 'thread', - threadId: 'T_summary', - root: comment({ - id: 30, - threadId: 'T_summary', - isResolved: false, - url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' - }), - replies: [] + // Why: the ack resolves after delivery, by which point the panel may show another review — + // the host target must ride along in the payload, not be re-read from live state. + it('carries the snapshotted GitLab target through take', () => { + const payload: PendingPRCommentAiAck = { + reviewContextKey: 'repo::main::gitlab::7::abc', + provider: 'gitlab', + selectedGroups: [openThread('D1')], + gitlabTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', iid: 7 } } - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [summaryThread], - deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread, - canReply: true, - replyInThread: vi.fn(), - replyAsConversation: vi.fn().mockResolvedValue(false) - } + clearPendingPRCommentAiAck() + setPendingPRCommentAiAck(payload) + expect(takePendingPRCommentAiAck()?.gitlabTarget).toEqual({ + repoPath: '/repos/widgets', + repoId: 'repo-1', + iid: 7 }) - - expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) - expect(resolveThread).not.toHaveBeenCalled() }) - it('reports a resolvable thread as skipped when the panel context moved', async () => { - const resolveThread = vi.fn() - - const result = await acknowledgePRCommentsAfterAiLaunch({ - groups: [openThread('T1')], - deps: { - isStillCurrent: () => false, - isThreadStillResolvable: () => true, - resolveThread, - canReply: true, - replyInThread: vi.fn().mockResolvedValue(true), - replyAsConversation: vi.fn() - } - }) - - expect(result).toEqual({ resolved: 0, replied: 1, skipped: 1, failed: 0 }) - expect(resolveThread).not.toHaveBeenCalled() - }) - - it('runs groups with bounded concurrency, replying before resolving in each group', async () => { + it('runs groups with bounded concurrency', async () => { const groups = Array.from({ length: 9 }, (_, index) => openThread(`T${index}`, 100 + index)) - const order: string[] = [] let inFlight = 0 let peakInFlight = 0 - const track = async (label: string, value: T): Promise => { - inFlight += 1 - peakInFlight = Math.max(peakInFlight, inFlight) - order.push(label) - await Promise.resolve() - inFlight -= 1 - return value - } - const result = await acknowledgePRCommentsAfterAiLaunch({ groups, deps: { - isStillCurrent: () => true, - isThreadStillResolvable: () => true, - resolveThread: (threadId) => track(`resolve:${threadId}`, true), + resolveThread: async () => { + inFlight += 1 + peakInFlight = Math.max(peakInFlight, inFlight) + await Promise.resolve() + inFlight -= 1 + return true + }, canReply: true, - replyInThread: (comment) => track(`reply:${comment.threadId}`, true), + replyInThread: vi.fn(), replyAsConversation: vi.fn() } }) - expect(result).toEqual({ resolved: 9, replied: 9, skipped: 0, failed: 0 }) + expect(result).toEqual({ resolved: 9, replied: 0, skipped: 0, failed: 0 }) expect(peakInFlight).toBeGreaterThan(1) expect(peakInFlight).toBeLessThanOrEqual(PR_COMMENT_ACK_MAX_CONCURRENCY) - for (const group of groups) { - expect(order.indexOf(`reply:${group.threadId}`)).toBeLessThan( - order.indexOf(`resolve:${group.threadId}`) - ) + }) +}) + +describe('getPRCommentGroupsNeedingReply', () => { + it('keeps only the groups the host cannot close for us', () => { + const summary = codeRabbitReviewSummary(30) + const conversation = standalone(31) + const resolvedThread: PRCommentGroup = { + kind: 'thread', + threadId: 'T_done', + root: comment({ id: 12, threadId: 'T_done', isResolved: true }), + replies: [] } + + expect( + getPRCommentGroupsNeedingReply([ + openThread('T1'), + summary, + conversation, + resolvedThread, + openThread('T2', 11) + ]) + ).toEqual([summary, conversation, resolvedThread]) + }) +}) + +describe('hasPRCommentGroupNeedingReply', () => { + it('matches the filtered list without materializing it', () => { + expect(hasPRCommentGroupNeedingReply([openThread('T1'), openThread('T2', 11)])).toBe(false) + expect(hasPRCommentGroupNeedingReply([openThread('T1'), standalone(31)])).toBe(true) + expect(hasPRCommentGroupNeedingReply([])).toBe(false) }) }) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index 8eadffdcdfb..6c427f84acc 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -15,9 +15,7 @@ export type PRCommentAiLaunchAckCounts = { } export type PRCommentAiLaunchAckDeps = { - isStillCurrent: () => boolean - /** True when the thread is still open and host-resolvable in live comment state. */ - isThreadStillResolvable: (threadId: string) => boolean + /** Resolve a host thread. Always called against the threadId snapshotted at launch. */ resolveThread: (threadId: string) => Promise /** Provider supports posting a reply (GitHub in the Checks panel today). */ canReply: boolean @@ -164,26 +162,37 @@ async function mapWithBoundedConcurrency( type BatchedConversationReply = { counts: PRCommentAiLaunchAckCounts handled: ReadonlySet - replyOk: boolean } const NO_BATCHED_CONVERSATION_REPLY: BatchedConversationReply = { counts: EMPTY_ACK_COUNTS, - handled: new Set(), - replyOk: false + handled: new Set() +} + +/** Groups that need a posted reply because the host cannot close them for us. */ +export function getPRCommentGroupsNeedingReply( + groups: readonly PRCommentGroup[] +): PRCommentGroup[] { + return groups.filter((group) => !isResolvablePRCommentGroup(group)) +} + +/** Emptiness test for the above without materializing the filtered list. */ +export function hasPRCommentGroupNeedingReply(groups: readonly PRCommentGroup[]): boolean { + return groups.some((group) => !isResolvablePRCommentGroup(group)) } /** - * After launching an agent for selected review feedback: - * post a fixing reply (one nested reply per review thread, one combined conversation - * comment for everything that has no nested-reply endpoint), then resolve host threads. + * After launching an agent for selected review feedback, acknowledge every selection + * exactly once: host-resolvable threads are resolved, and anything the host cannot close + * gets a fixing reply (one nested reply per review thread, one combined conversation + * comment for everything with no nested-reply endpoint). * - * Why: replies use a snapshotted GitHub target and must not abort just because - * the checks-panel async key churned mid-launch. isStillCurrent only gates - * host-thread resolve + UI refresh, not the reply posts themselves. + * Why resolve instead of reply: a resolved thread collapses on the host, which *is* the + * acknowledgement — an extra "Fixing." comment only adds noise to a thread the user can + * already see is handled. * - * Groups run through a bounded pool; reply-before-resolve stays serial *within* - * a group because the resolve decision reads live comment state the reply mutates. + * Every host call targets state snapshotted at launch, so a slow agent start or the user + * navigating away can no longer silently drop the resolve. Groups run through a bounded pool. */ export async function acknowledgePRCommentsAfterAiLaunch(args: { groups: readonly PRCommentGroup[] @@ -192,7 +201,7 @@ export async function acknowledgePRCommentsAfterAiLaunch(args: { // Why: conversation comments have no replies endpoint, so one post per selected item // would spam the PR timeline with near-identical "Fixing." comments. const conversationGroups = args.deps.canReply - ? args.groups.filter( + ? getPRCommentGroupsNeedingReply(args.groups).filter( (group) => !canPostPRReviewThreadReply(getPRCommentGroupReplyTarget(group)) ) : [] @@ -228,8 +237,7 @@ async function postBatchedConversationReply( return { // Why: one host POST covers the whole set, so the toast must count it once. counts: { ...EMPTY_ACK_COUNTS, replied: replyOk ? 1 : 0, failed: replyOk ? 0 : 1 }, - handled: new Set(groups), - replyOk + handled: new Set(groups) } } @@ -239,50 +247,34 @@ async function acknowledgePRCommentGroup( batch: BatchedConversationReply ): Promise { const counts = { ...EMPTY_ACK_COUNTS } - const threadId = - group.kind === 'thread' && isResolvablePRCommentGroup(group) ? group.threadId : null - if (deps.canReply) { - const replyOk = batch.handled.has(group) - ? batch.replyOk - : await postInThreadFixingReply(group, deps, counts) - // Why: resolving a thread whose fixing reply never landed closes it silently — - // the user was promised an ack, so leave it open and let the toast report the failure. - if (!replyOk) { - return counts - } - } - - if (threadId == null || !deps.isThreadStillResolvable(threadId)) { - if (!deps.canReply) { - counts.skipped += 1 + if (isResolvablePRCommentGroup(group)) { + if (await deps.resolveThread(group.threadId)) { + counts.resolved += 1 + } else { + counts.failed += 1 } return counts } - // Why: resolve is UI/host-state sensitive; skip it if the panel context moved. - // Replies already used a snapshotted target and must not be gated the same way. - if (!deps.isStillCurrent()) { + if (!deps.canReply) { counts.skipped += 1 return counts } - - if (await deps.resolveThread(threadId)) { - counts.resolved += 1 - } else { - // Why: a resolve failure leaves the thread open on the host regardless of whether - // the reply landed; counting it only when !canReply made GitHub failures invisible. - counts.failed += 1 + // The combined conversation post already covered this group and counted itself. + if (batch.handled.has(group)) { + return counts } + await postInThreadFixingReply(group, deps, counts) return counts } -/** Mutates counts in place; returns whether the nested reply landed. */ +/** Mutates counts in place. */ async function postInThreadFixingReply( group: PRCommentGroup, deps: PRCommentAiLaunchAckDeps, counts: PRCommentAiLaunchAckCounts -): Promise { +): Promise { const replyOk = await deps.replyInThread( getPRCommentGroupReplyTarget(group), PR_COMMENT_AI_FIXING_REPLY @@ -292,24 +284,41 @@ async function postInThreadFixingReply( } else { counts.failed += 1 } - return replyOk } -/** Snapshotted at queue time so the post-launch ack does not depend on live React state. */ -export type PendingPRCommentAiAckGithubTarget = { +/** + * Snapshotted at queue time so the post-launch ack does not depend on live React state. + * Resolving a thread only needs the PR handle — prRepo is optional on resolveReviewThread, + * so a PR cache entry without it must still be able to ack by resolving. + */ +export type PendingPRCommentAiAckGithubResolveTarget = { repoPath: string repoId: string prNumber: number + prRepo?: NonNullable +} + +/** The reply endpoints genuinely need prRepo, so they get the stricter target. */ +export type PendingPRCommentAiAckGithubTarget = PendingPRCommentAiAckGithubResolveTarget & { prRepo: NonNullable } +/** Snapshotted at queue time so the post-launch ack does not depend on live React state. */ +export type PendingPRCommentAiAckGitlabTarget = { + repoPath: string + repoId: string + iid: number +} + /** What a queued comment-resolution launch must still ack once its prompt is delivered. */ export type PendingPRCommentAiAck = { reviewContextKey: string provider: HostedReviewInfo['provider'] - selectedThreadIds: string[] selectedGroups: PRCommentGroup[] + /** Reply target: requires prRepo. */ githubTarget?: PendingPRCommentAiAckGithubTarget + githubResolveTarget?: PendingPRCommentAiAckGithubResolveTarget + gitlabTarget?: PendingPRCommentAiAckGitlabTarget } /** Durable pending ack payload so dialog close / re-renders cannot drop it before launch. */ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8a525dcde16..a1361ab527b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10695,7 +10695,7 @@ "653c105ecc": "More PR actions", "f316a8ca2b": "No unresolved comments selected.", "d00ebdc402": "Resolve {{value0}} Comments With AI", - "ed3f79c031": "Review the prompt before starting an agent. After the prompt is delivered, Orca replies to the selected comments and resolves host threads when possible.", + "5eb2163b6b": "Review the prompt before starting an agent. After the prompt is delivered, Orca resolves the selected host threads and replies to comments it cannot resolve.", "f273f2271c": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.{{value4}}", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.", "495b2f8c4b": "Started the agent, but could not resolve or reply on the selected comments.", @@ -10710,6 +10710,7 @@ "branch": "Sync Branch" }, "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", + "430f1a62d4": "Could not resolve the selected thread on the host.", "updateReactionFailed": "Failed to update reaction." }, "CreatePullRequestDialog": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c995ff3a8ea..a14e64ec424 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10159,7 +10159,7 @@ "653c105ecc": "Más acciones de PR", "f316a8ca2b": "No hay comentarios sin resolver seleccionados.", "d00ebdc402": "Resolver comentarios de {{value0}} con IA", - "ed3f79c031": "Revisa el prompt antes de iniciar un agente. Una vez entregado el prompt, Orca responde a los comentarios seleccionados y resuelve los hilos en el host cuando es posible.", + "5eb2163b6b": "Revisa el prompt antes de iniciar un agente. Una vez entregado el prompt, Orca resuelve los hilos seleccionados en el host y responde a los comentarios que no puede resolver.", "f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.{{value4}}", "aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.", "495b2f8c4b": "Agente iniciado, pero no se pudieron resolver ni responder los comentarios seleccionados.", @@ -10173,7 +10173,8 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder." + "7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder.", + "430f1a62d4": "No se pudo resolver el hilo seleccionado en el host." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33c6c5cfccc..8e9b313dd4c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10159,7 +10159,7 @@ "653c105ecc": "その他の PR 操作", "f316a8ca2b": "未解決のコメントが選択されていません。", "d00ebdc402": "{{value0}} のコメントを AI で解決", - "ed3f79c031": "Agent を開始する前にプロンプトを確認してください。プロンプトの送信後、Orca は選択したコメントに返信し、可能な場合はホスト上のスレッドを解決します。", + "5eb2163b6b": "Agent を開始する前にプロンプトを確認してください。プロンプトの送信後、Orca は選択したホスト上のスレッドを解決し、解決できないコメントには返信します。", "f273f2271c": "Agent を開始しました。{{value0}} 件を解決し、{{value1}} 件に返信し、{{value2}} 件をスキップし、{{value3}} 件の処理に失敗しました。{{value4}}", "aa95b81a3a": "Agent を開始しました。{{value0}} 件を解決し、{{value1}} 件に返信し、{{value2}} 件をスキップし、{{value3}} 件の処理に失敗しました。", "495b2f8c4b": "Agent を開始しましたが、選択したコメントの解決または返信ができませんでした。", @@ -10173,7 +10173,8 @@ "pending": "同期中…", "branch": "ブランチを同期" }, - "7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。" + "7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。", + "430f1a62d4": "選択したスレッドをホスト上で解決できませんでした。" }, "CreatePullRequestDialog": { "2bc1b4345e": "キャンセル", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 27db9b4690f..364bc1860a0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10159,7 +10159,7 @@ "653c105ecc": "더 많은 PR 작업", "f316a8ca2b": "선택한 해결되지 않은 댓글이 없습니다.", "d00ebdc402": "AI로 {{value0}} 댓글 해결", - "ed3f79c031": "에이전트를 시작하기 전에 프롬프트를 확인하세요. 프롬프트가 전달된 후 Orca가 선택한 댓글에 답글을 남기고 가능한 경우 호스트 스레드를 해결합니다.", + "5eb2163b6b": "에이전트를 시작하기 전에 프롬프트를 확인하세요. 프롬프트가 전달된 후 Orca가 선택한 호스트 스레드를 해결하고, 해결할 수 없는 댓글에는 답글을 남깁니다.", "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.{{value4}}", "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.", "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결하거나 답글할 수 없습니다.", @@ -10173,7 +10173,8 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다." + "7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다.", + "430f1a62d4": "선택한 스레드를 호스트에서 해결하지 못했습니다." }, "CreatePullRequestDialog": { "2bc1b4345e": "취소", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 46f093cd08e..2e85a5074b1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10179,7 +10179,7 @@ "653c105ecc": "更多 PR 操作", "f316a8ca2b": "未选择未解决的评论。", "d00ebdc402": "使用 AI 解决 {{value0}} 评论", - "ed3f79c031": "启动 agent 前请检查提示词。提示词送达后,Orca 会回复选中的评论,并在可能时解决主机上的讨论串。", + "5eb2163b6b": "启动 agent 前请检查提示词。提示词送达后,Orca 会解决选中的主机讨论串,并回复无法解决的评论。", "f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。{{value4}}", "aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。", "495b2f8c4b": "已启动智能体,但无法解决或回复选中的评论。", @@ -10193,7 +10193,8 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "无法确定要回复的 GitHub PR。" + "7e4b2a19c0": "无法确定要回复的 GitHub PR。", + "430f1a62d4": "无法在主机上解决选中的讨论串。" }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", From 4c2a10d15782fcdca63435d0bad81b7b3f01c969 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:11:34 -0700 Subject: [PATCH 014/109] Fix smart sort ranking of done agents by completion time (#13899) * Fix smart sort ranking of done agents by completion time Completed entries stayed in the Done sort class indefinitely when same-state writes refreshed updatedAt without moving stateStartedAt. Introduce agentEntryCompletionAt() to use actual completion time for both age display and sort eligibility, ensuring consistent aging regardless of hook updates. * Fix smart sort ranking of done agents by completion time Co-authored-by: Orca --------- Co-authored-by: Orca --- .../agent-finished-timestamp.test.ts | 15 ++ .../dashboard/agent-finished-timestamp.ts | 13 +- .../sidebar/smart-attention.test.ts | 64 ++++++++ .../src/components/sidebar/smart-attention.ts | 44 ++---- .../src/components/sidebar/smart-sort.test.ts | 71 ++++++++- .../src/runtime/web-session-tabs-sync.test.ts | 58 ++++++++ .../src/runtime/web-session-tabs-sync.ts | 6 + .../agent-status-freshness-scheduler.test.ts | 139 ++++++++++++++++++ .../agent-status-freshness-scheduler.ts | 27 +++- .../src/store/slices/agent-status.test.ts | 23 +++ src/renderer/src/store/slices/agent-status.ts | 8 +- src/shared/agent-completion-time.ts | 43 ++++++ 12 files changed, 473 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts create mode 100644 src/shared/agent-completion-time.ts diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 92df277ef9d..84116333a1c 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { lastEnteredDoneAt } from './agent-finished-timestamp' @@ -37,3 +38,17 @@ describe('lastEnteredDoneAt session boundaries (STA-3386)', () => { ).toBe(1_000) }) }) + +describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { + it('times an ordinary completion from stateStartedAt, not updatedAt', () => { + const entry = doneEntry({ stateStartedAt: 2_000, updatedAt: 190_000 }) + expect(lastEnteredDoneAt(row(entry))).toBe(2_000) + expect(agentEntryCompletionAt(entry)).toBe(2_000) + }) + + it('still shows when an interrupted turn stopped, though it never ranks as done', () => { + const entry = doneEntry({ interrupted: true, stateStartedAt: 2_000 }) + expect(lastEnteredDoneAt(row(entry))).toBe(2_000) + expect(agentEntryCompletionAt(entry)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 6449e570fe1..599a07d118a 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,3 +1,4 @@ +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import type { DashboardAgentRow } from './useDashboardData' /** @@ -15,9 +16,15 @@ export function lastEnteredDoneAt( return null } const entry = agent.entry - // Why: a session-boundary done means the session connected idle (STA-3386) — nothing - // finished; fall through to history (which never records session boundaries). - if (entry.state === 'done' && entry.sessionBoundary !== true) { + // Why: same primitive Smart Sort ranks on, so the displayed age and Done eligibility share a clock. + // (Session-boundary `done` means the session connected idle — STA-3386 — so it resolves to the real + // completion it displaced, if any.) + const completedAt = agentEntryCompletionAt(entry) + if (completedAt !== null) { + return completedAt + } + // Why: display is looser than ranking — an interrupted turn still shows when it stopped. + if (entry.state === 'done' && entry.interrupted === true && entry.sessionBoundary !== true) { return entry.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 2c4324938cd..8723cf937fc 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -195,6 +195,70 @@ describe('resolveAttention', () => { }) }) + it('drops a done pane out of Class 2 once the completion itself ages out', () => { + const justInside = makeEntry({ + paneKey: 't:1', + state: 'done', + stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS, + updatedAt: NOW - 1_000 + }) + expect(resolveAttention([hookPane(justInside)], NOW)).toEqual({ + cls: 2, + attentionTimestamp: NOW - AGENT_STATUS_STALE_AFTER_MS + }) + + const justOutside = makeEntry({ + paneKey: 't:1', + state: 'done', + stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + updatedAt: NOW - 1_000 + }) + expect(resolveAttention([hookPane(justOutside)], NOW)).toEqual(IDLE) + }) + + it('does not let same-state done writes extend Class 2 eligibility', () => { + // The captured regression: updatedAt was 3m04s newer than the completion, keeping a 32m-old + // "done" row above two spinners. + const entry = makeEntry({ + paneKey: 't:1', + state: 'done', + stateStartedAt: NOW - 32 * 60_000, + updatedAt: NOW - 29 * 60_000 + }) + expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE) + }) + + it('keeps an expired done pane from masking a live working sibling', () => { + const expiredDone = makeEntry({ + paneKey: 't:1', + state: 'done', + stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000, + updatedAt: NOW - 1_000 + }) + const working = makeEntry({ + paneKey: 't:2', + state: 'working', + stateStartedAt: NOW - 10_000, + updatedAt: NOW - 1_000 + }) + expect(resolveAttention(hookPanes([expiredDone, working]), NOW)).toEqual({ + cls: 3, + attentionTimestamp: NOW - 10_000 + }) + }) + + it('ages out a session-boundary completion from its real completion time', () => { + const boundary = makeEntry({ + paneKey: 't:1', + state: 'done', + sessionBoundary: true, + stateStartedAt: NOW - 1_000, + updatedAt: NOW - 500, + stateHistory: [makeHistory('done', NOW - AGENT_STATUS_STALE_AFTER_MS - 1)] + }) + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual(IDLE) + }) + it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index d1a182cb224..5238a9091eb 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,4 +1,5 @@ import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' @@ -15,9 +16,9 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' /** * Ordinal class for the "Smart" sort. Lower number = more attention-demanding. * 1 — Needs you (`blocked` / `waiting`) - * 2 — Done (`done`, not interrupted) + * 2 — Done (`done`, not interrupted, completed within AGENT_STATUS_STALE_AFTER_MS) * 3 — Working (`working`) - * 4 — Idle (no live entry, stale entry, or interrupted `done`) + * 4 — Idle (no live entry, stale entry, interrupted `done`, or an aged-out completion) * * Primary sort key; ties fall back to the attention timestamp. See docs/smart-worktree-order-redesign.md. */ @@ -34,7 +35,7 @@ export type AttentionCause = 'blocked' | 'waiting' | 'title-heuristic' * Per-worktree resolution computed once before sorting. * * `attentionTimestamp` by class: - * - Class 1 / 2: `stateStartedAt` of the current entry. + * - Class 1: `stateStartedAt` of the current entry. Class 2: the entry's completion time. * - Class 3: `stateStartedAt` of the most recent prior `done`/`blocked`/`waiting` entry, * falling back to the current `working` `stateStartedAt`. * - Class 4: `0` — comparator drops to `effectiveRecentActivity` for idle ordering. @@ -98,21 +99,6 @@ export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): return max > 0 ? max : null } -function mostRecentCompletedTurnInHistory(history: AgentStateHistoryEntry[]): number | null { - let max = 0 - for (const entry of history) { - if ( - entry.state === 'done' && - entry.interrupted !== true && - Number.isFinite(entry.startedAt) && - entry.startedAt > max - ) { - max = entry.startedAt - } - } - return max > 0 ? max : null -} - /** * One pane's contribution to a worktree's attention class. Fresh hook entries win; hookless * panes fall back to the title heuristic (design doc Edge case 9). Authority is per-pane, not per-worktree. @@ -152,21 +138,19 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten ts = entry.stateStartedAt cause = entry.state } else if (entry.state === 'done') { - // Why: interrupted `done` (Ctrl+C) means the user is done with the turn; treat as idle, not Class 2. - if (entry.interrupted) { + // Why: null covers interrupted `done` (Ctrl+C — user is finished with it) and idle session + // boundaries; neither is attention. + const completedAt = agentEntryCompletionAt(entry) + if (completedAt === null) { + continue + } + // Why: same-state `done` writes advance updatedAt without moving the completion, so the hook + // freshness gate alone can keep a row in Class 2 long after the UI shows it aged out. + if (now - completedAt > AGENT_STATUS_STALE_AFTER_MS) { continue } cls = 2 - if (entry.sessionBoundary) { - // Why: idle connect is not new attention; only preserve a real completion it displaced. - const completedAt = mostRecentCompletedTurnInHistory(entry.stateHistory) - if (completedAt === null) { - continue - } - ts = completedAt - } else { - ts = entry.stateStartedAt - } + ts = completedAt } else { // working cls = 3 diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 47c437f5199..75af8fae1cc 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -109,7 +109,8 @@ function ptyMapForTabs(tabsByWorktree: Record): Record, agentStatusByPaneKey: Record @@ -120,9 +121,17 @@ function sortSmart( agentStatusByPaneKey, {}, ptyMapForTabs(tabsByWorktree), - NOW + now ) - return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, NOW, attention)) + return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, now, attention)) +} + +function sortSmart( + worktrees: Worktree[], + tabsByWorktree: Record, + agentStatusByPaneKey: Record +): Worktree[] { + return sortSmartAt(NOW, worktrees, tabsByWorktree, agentStatusByPaneKey) } describe('smart sort — class invariants', () => { @@ -354,6 +363,62 @@ describe('smart sort — interrupted and stale handling', () => { }) }) +describe('smart sort — completed-agent eligibility clock', () => { + // Captured regression (docs/smart-sort-agent-activity-findings.md): a `done` row whose + // updatedAt was 3m04s newer than its completion outranked two live spinners. + const SCREENSHOT_AT = new Date('2026-03-27T21:44:39.000Z').getTime() + const at = (hhmmss: string): number => new Date(`2026-03-27T${hhmmss}.000Z`).getTime() + + function capturedFixture(workingUpdatedAt = at('21:44:30')) { + const done = makeWorktree({ id: 'fix-linear-persistent', displayName: 'fix-linear-persistent' }) + const workingA = makeWorktree({ id: 'allow-editing', displayName: 'allow-editing' }) + const workingB = makeWorktree({ id: 'resume-terminal', displayName: 'resume-terminal' }) + const tabs = { + [done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })], + [workingA.id]: [makeTab({ id: 'tab-a', worktreeId: workingA.id })], + [workingB.id]: [makeTab({ id: 'tab-b', worktreeId: workingB.id })] + } + const entries = { + [paneKey('tab-done', '1')]: makeEntry({ + paneKey: paneKey('tab-done', '1'), + state: 'done', + stateStartedAt: at('21:12:09'), + // Same-state `done` writes pushed updatedAt 3m04s past the completion. + updatedAt: at('21:15:13') + }), + [paneKey('tab-a', '1')]: makeEntry({ + paneKey: paneKey('tab-a', '1'), + state: 'working', + stateStartedAt: workingUpdatedAt - 68_000, + updatedAt: workingUpdatedAt + }), + [paneKey('tab-b', '1')]: makeEntry({ + paneKey: paneKey('tab-b', '1'), + state: 'working', + stateStartedAt: workingUpdatedAt - 145_000, + updatedAt: workingUpdatedAt - 5_000 + }) + } + return { worktrees: [done, workingA, workingB], tabs, entries } + } + + it('ranks the two spinners above the 32-minute-old completion', () => { + const { worktrees, tabs, entries } = capturedFixture() + const sorted = sortSmartAt(SCREENSHOT_AT, worktrees, tabs, entries) + expect(sorted.map((w) => w.id)).toEqual([ + 'allow-editing', + 'resume-terminal', + 'fix-linear-persistent' + ]) + }) + + it('still ranks that completion above the spinners inside its own window', () => { + const { worktrees, tabs, entries } = capturedFixture(at('21:39:50')) + const sorted = sortSmartAt(at('21:40:00'), worktrees, tabs, entries) + expect(sorted[0].id).toBe('fix-linear-persistent') + }) +}) + describe('smart sort — Class 4 ordering', () => { it('breaks ties on effectiveRecentActivity, then displayName', () => { const recentlyActive = makeWorktree({ 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 4d94c99e728..526833a5969 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -2300,6 +2300,64 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe((initialState.sortEpoch ?? 0) + 1) }) + it('bumps sort epoch when a mirrored same-state done update becomes a completion', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const initialSnapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Codex', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'done', + prompt: 'same prompt', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 2_000, + agentType: 'codex', + paneKey: hostPaneKey, + stateHistory: [], + interrupted: true + } + } + ]) + const initialPatch = applyWebSessionTabsSnapshot( + makeState(), + initialSnapshot, + ENV, + NOW + ) as Partial + const initialState = { ...makeState(), ...initialPatch } + + const patch = applyWebSessionTabsSnapshot( + initialState, + { + ...initialSnapshot, + snapshotVersion: 2, + tabs: initialSnapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.agentStatus + ? { + ...tab, + agentStatus: { + ...tab.agentStatus, + updatedAt: NOW, + interrupted: undefined + } + } + : tab + ) + }, + ENV, + NOW + ) as Partial + + expect(patch.agentStatusEpoch).toBe((initialState.agentStatusEpoch ?? 0) + 1) + expect(patch.sortEpoch).toBe((initialState.sortEpoch ?? 0) + 1) + }) + it('hydrates multiple initial host snapshots in one merged patch', () => { const secondWorktree = 'repo::/other-worktree' const patch = applyWebSessionTabsSnapshots( diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 174a239ac5a..2e28113c2d8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -8,6 +8,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../shared/agent-status-types' +import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, @@ -1278,12 +1279,17 @@ function buildMirroredAgentStatusPatch( existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId const entryFreshnessChanged = !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) + const doneAttentionChanged = + existing?.state === 'done' && + entry.state === 'done' && + agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) const entrySortRelevantChange = !existing || existing.state !== entry.state || !isAgentStatusFresh(existing, now) || entryFreshnessChanged || entryAttributionChanged || + doneAttentionChanged || isMirroredCommandCodeTurnBump(existing, entry) aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange sortRelevantChange = sortRelevantChange || entrySortRelevantChange diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts new file mode 100644 index 00000000000..93d49d79404 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { createFreshnessScheduler } from './agent-status-freshness-scheduler' + +const NOW = new Date('2026-04-09T12:00:00.000Z').getTime() + +function doneEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + state: 'done', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function setup(entries: AgentStatusEntry[]) { + const bumpEpochs = vi.fn() + const scheduler = createFreshnessScheduler({ getEntries: () => entries, bumpEpochs }) + return { bumpEpochs, scheduler } +} + +describe('freshness scheduler completion deadlines', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('wakes at the completion deadline even while the hook row stays fresh', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + // Completion is 25m old; same-state `done` writes have kept updatedAt at now. + const completedAt = NOW - 25 * 60_000 + const { bumpEpochs, scheduler } = setup([ + doneEntry({ stateStartedAt: completedAt, updatedAt: NOW }) + ]) + + scheduler.schedule() + vi.advanceTimersByTime(completedAt + AGENT_STATUS_STALE_AFTER_MS - NOW) + expect(bumpEpochs).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(bumpEpochs).toHaveBeenCalledTimes(1) + + scheduler.dispose() + }) + + it('keeps the strict-expiry wake armed when rescheduled exactly at the boundary', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const { bumpEpochs, scheduler } = setup([ + doneEntry({ + stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS, + updatedAt: NOW + }) + ]) + + scheduler.schedule() + vi.advanceTimersByTime(1) + expect(bumpEpochs).toHaveBeenCalledTimes(1) + + scheduler.dispose() + }) + + it('does not lose a completion deadline when an overdue timer is rescheduled after sleep', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const completedAt = NOW - 25 * 60_000 + const entries = [doneEntry({ stateStartedAt: completedAt, updatedAt: NOW })] + const { bumpEpochs, scheduler } = setup(entries) + + scheduler.schedule() + vi.setSystemTime(completedAt + AGENT_STATUS_STALE_AFTER_MS + 1) + entries[0] = doneEntry({ stateStartedAt: completedAt, updatedAt: Date.now() }) + scheduler.schedule() + + expect(bumpEpochs).toHaveBeenCalledTimes(1) + scheduler.schedule() + expect(bumpEpochs).toHaveBeenCalledTimes(1) + scheduler.dispose() + }) + + it('does not rearm an expired completion deadline in a zero-delay loop', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const completedAt = NOW - 25 * 60_000 + const { bumpEpochs, scheduler } = setup([ + doneEntry({ stateStartedAt: completedAt, updatedAt: NOW }) + ]) + + scheduler.schedule() + // Past both the completion deadline and the hook-freshness deadline. + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 60_000) + + // One bump for the completion boundary, one for hook freshness — never a spin. + expect(bumpEpochs).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + + scheduler.dispose() + }) + + it('ignores completions that cannot demand attention', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const completedAt = NOW - 25 * 60_000 + const { bumpEpochs, scheduler } = setup([ + doneEntry({ stateStartedAt: completedAt, updatedAt: NOW, interrupted: true }) + ]) + + scheduler.schedule() + vi.advanceTimersByTime(completedAt + AGENT_STATUS_STALE_AFTER_MS - NOW + 1) + expect(bumpEpochs).not.toHaveBeenCalled() + + // Only the hook-freshness deadline remains. + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS) + expect(bumpEpochs).toHaveBeenCalledTimes(1) + + scheduler.dispose() + }) + + it('still arms hook freshness for a working entry', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const { bumpEpochs, scheduler } = setup([ + doneEntry({ state: 'working', stateStartedAt: NOW, updatedAt: NOW }) + ]) + + scheduler.schedule() + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1) + expect(bumpEpochs).toHaveBeenCalledTimes(1) + + scheduler.dispose() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts index 628529dd007..93c3e54fee1 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts @@ -1,3 +1,4 @@ +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' @@ -21,6 +22,7 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes // `dispose()` in teardown — otherwise a real 30-minute setTimeout leaks // into the test process. let timer: ReturnType | null = null + let lastCheckedAt: number | null = null const clear = (): void => { if (timer !== null) { @@ -33,10 +35,12 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes clear() const entries = deps.getEntries() if (entries.length === 0) { + lastCheckedAt = null return } const now = Date.now() let nextExpiryAt = Number.POSITIVE_INFINITY + let crossedCompletionDeadline = false // Why: skip entries already past the stale boundary — they each contribute // exactly one epoch bump at crossing, and rescheduling on them would spin // the timer forever because the bump doesn't clear them from the map @@ -47,9 +51,29 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes // freshness-aware selectors can decay them immediately on that render. for (const entry of entries) { const expiryAt = entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS - if (expiryAt > now) { + if (expiryAt >= now) { nextExpiryAt = Math.min(nextExpiryAt, expiryAt) } + // Completion and hook freshness have independent expiry times. + const completedAt = agentEntryCompletionAt(entry) + if (completedAt !== null) { + const completionExpiryAt = completedAt + AGENT_STATUS_STALE_AFTER_MS + // Detect a missed completion expiry before a same-state update extends hook freshness. + if ( + lastCheckedAt !== null && + completionExpiryAt >= lastCheckedAt && + completionExpiryAt < now + ) { + crossedCompletionDeadline = true + } + if (completionExpiryAt >= now) { + nextExpiryAt = Math.min(nextExpiryAt, completionExpiryAt) + } + } + } + lastCheckedAt = now + if (crossedCompletionDeadline) { + deps.bumpEpochs() } if (!Number.isFinite(nextExpiryAt)) { return @@ -62,6 +86,7 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes timer = setTimeout(() => { timer = null deps.bumpEpochs() + lastCheckedAt = Date.now() schedule() }, delayMs) } diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 6965ca8bbb2..d937c3af53b 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -805,6 +805,29 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().sortEpoch).toBe(firstSortEpoch) }) + it('bumps sort epoch when a same-state done update changes completion eligibility', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'done', prompt: 'p1', agentType: 'claude', interrupted: true }, + 'claude', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + store + .getState() + .setAgentStatus('tab-1:1', { state: 'done', prompt: 'p1', agentType: 'claude' }, 'claude', { + updatedAt: 2_000, + stateStartedAt: 1_000 + }) + + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) + it('bumps global epochs when a stale same-state entry refreshes', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 271ab70829a..6dd2e60df1e 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -26,6 +26,7 @@ import { shouldSuppressInheritedTerminalStatus } from '../../../../shared/agent-status-identity' import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types' import { getRepoExecutionHostId, @@ -1982,13 +1983,18 @@ export const createAgentStatusSlice: StateCreator + +function mostRecentCompletedTurnInHistory( + history: readonly AgentStateHistoryEntry[] | undefined +): number | null { + let max = 0 + for (const row of history ?? []) { + if ( + row.state === 'done' && + row.interrupted !== true && + Number.isFinite(row.startedAt) && + row.startedAt > max + ) { + max = row.startedAt + } + } + return max > 0 ? max : null +} + +/** + * When the entry's agent last actually COMPLETED a turn, or null when nothing qualifies. + * One clock for both the displayed completion age and Smart Sort's Done eligibility, so a row + * can't rank as freshly done while showing an age past the staleness threshold. + * + * A completion is only: + * - a non-interrupted `done` (its `stateStartedAt` — unmoved by same-state tool/prompt pings); or + * - for a session-boundary `done` (connected idle, not a turn), the real completion it displaced. + */ +export function agentEntryCompletionAt(entry: AgentCompletionSource): number | null { + if (entry.state !== 'done' || entry.interrupted === true) { + return null + } + if (entry.sessionBoundary === true) { + return mostRecentCompletedTurnInHistory(entry.stateHistory) + } + return Number.isFinite(entry.stateStartedAt) ? entry.stateStartedAt : null +} From 991a3fe9637df160a2b420b4d3c6466cf0af09dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:19:43 -0700 Subject: [PATCH 015/109] chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable eleven oxlint rules that simplify code without changing behavior, and fix every existing violation. Each candidate was gated on measured cost rather than assumption, so rules that regressed runtime performance or type checking were dropped instead of suppressed. typescript/no-redundant-type-constituents is the largest addition: 113 sites, no autofix. Dead constituents are deleted. Where the redundant literal existed to document intent (`string | 'all'`), it is preserved as `(string & {})`, which keeps the autocomplete hint the original code was reaching for instead of flattening it away. The rule also caught a broken import — remote-shared-control-retirement-probe.ts pulled RuntimeStatus from src/shared/types, which does not export it, so the type silently degraded to `any`; no tsconfig covers that file, so tsc never saw it. oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets minimum-release-age=4320 and 1.78.0 is younger than that window. Rules evaluated and rejected, with what disqualified each: - prefer-string-raw: String.raw is a runtime call, not a literal (184x slower) - prefer-string-replace-all: 26% slower - text-encoding-identifier-case: ~5% slower, reproducible - prefer-spread: [...str] is 110% slower than split('') and differs on surrogates - no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors) - prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks - object-shorthand: rewrites source text asserted by a tracked reliability gate - switch-case-braces: pushes ten files past max-lines, which cannot be suppressed - no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs - arrow-body-style: 115 violations have no fix, and it breaks max-lines - newline-after-import: false-positives on the leading-semicolon ASI idiom electron-vite-output-contract asserted on the literal Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which rejects inherited keys identically. --- .oxlintrc.json | 10 ++ config/electron-vite-target.config.ts | 2 +- .../oxlint-code-quality-native-plugins.json | 1 + config/oxlint-code-quality-type-aware.json | 1 + .../build-notification-status-macos.mjs | 2 +- config/scripts/build-windows-cli-launcher.mjs | 2 +- .../computer-use-modifier-safety.test.mjs | 2 +- .../electron-vite-output-contract.test.ts | 3 +- config/scripts/ensure-native-runtime.mjs | 2 +- .../generate-skill-bundle-manifest.mjs | 6 +- ...-worker-recovery-persistence-benchmark.mjs | 4 +- ...s-computer-helper-owner-loss-benchmark.mjs | 2 +- .../mjpeg-frame-parser-alloc-benchmark.mjs | 8 +- .../scripts/relay-watcher-fault-harness.mjs | 4 +- .../remote-shared-control-retirement-probe.ts | 3 +- .../run-ssh-staged-upload-reliability.mjs | 4 +- config/scripts/terminal-e2e-helpers.mjs | 4 +- .../verify-agent-hook-stdin-lifecycle.mjs | 2 +- config/scripts/verify-computer-native.mjs | 8 +- config/scripts/wsl-git-shell-benchmark.mjs | 2 +- .../mobile-markdown-preview-html.ts | 2 +- .../mobile-markdown-preview-tag-stripper.ts | 6 +- mobile/src/files/file-tree.ts | 2 +- .../src/hooks/use-active-worktree-scroll.ts | 2 +- .../mobile-diff-review-screen-model.ts | 2 +- .../mobile-native-chat-draft-reconcile.ts | 2 +- mobile/src/transport/host-store.ts | 8 +- .../mobile-relay-host-overlay-store.ts | 2 +- mobile/src/transport/rpc-response-shape.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 162 +++++++++--------- .../managed-hook-owner-identity.ts | 2 +- .../session-scanner-service-client.ts | 4 +- .../ai-vault/session-scanner-worker-client.ts | 2 +- src/main/automations/hermes-cron-output.ts | 4 +- src/main/browser/agent-browser-bridge.test.ts | 4 +- src/main/browser/browser-cookie-import.ts | 2 +- ...owser-session-registry.persistence.test.ts | 2 +- src/main/browser/browser-webauthn-access.ts | 2 +- .../browser/cdp-bridge-integration.test.ts | 2 +- src/main/cli/wsl-cli-installer.ts | 2 +- src/main/cli/wsl-cli-registration-registry.ts | 2 +- src/main/codex-accounts/service.ts | 2 +- src/main/codex/codex-real-home-path.ts | 2 +- .../codex/codex-session-resume-home.test.ts | 4 +- .../macos-native-provider-transport.ts | 2 +- .../daemon/daemon-incarnation-evidence.ts | 2 +- src/main/daemon/daemon-server.ts | 2 +- .../daemon/macos-login-session-death-watch.ts | 4 +- src/main/daemon/terminal-session-teardown.ts | 3 +- .../terminal-view-attribute-responder.ts | 2 +- src/main/daemon/xterm-env-polyfill.ts | 2 +- src/main/emulator/android/adb-devices.ts | 2 +- src/main/emulator/mjpeg-frame-parser.ts | 4 +- src/main/git/repo.ts | 2 +- src/main/git/wsl-git-read-environment.ts | 2 +- src/main/github/stacked-pr-creation.ts | 2 +- src/main/ipc/ephemeral-vm.test.ts | 2 +- src/main/ipc/filesystem-import.test.ts | 4 +- .../ipc/filesystem-search-rg-timeout.test.ts | 2 +- .../filesystem-watcher-dormant-rearm.test.ts | 2 +- .../filesystem-watcher-large-batch.test.ts | 2 +- ...lesystem-watcher-local-unsubscribe.test.ts | 2 +- ...filesystem-watcher-native-capacity.test.ts | 2 +- src/main/ipc/filesystem-watcher-real.test.ts | 2 +- .../filesystem-watcher-remote-batch.test.ts | 2 +- ...system-watcher-remote-cancellation.test.ts | 2 +- .../filesystem-watcher-remote-rearm.test.ts | 2 +- ...ilesystem-watcher-removal-deadline.test.ts | 2 +- ...filesystem-watcher-terminal-resync.test.ts | 2 +- ...lesystem-watcher-unwatchable-roots.test.ts | 2 +- src/main/ipc/filesystem-watcher.test.ts | 2 +- src/main/ipc/filesystem.test.ts | 2 +- src/main/ipc/notebook.test.ts | 2 +- .../ipc/runtime-watcher-quarantine-queue.ts | 2 +- .../ipc/ssh-pty-legacy-projection-record.ts | 2 +- src/main/ipc/ssh.ts | 4 +- src/main/ipc/workspace-ports.test.ts | 2 +- src/main/ipc/worktree-common-git-directory.ts | 2 +- src/main/ipc/worktree-git-common-polling.ts | 2 +- src/main/jira/attachment-images.ts | 2 +- src/main/jira/client.ts | 2 +- src/main/keybindings/keybinding-file.ts | 6 +- src/main/linear/issue-context-client.ts | 18 +- src/main/linear/mcp-issue-list.ts | 2 +- .../windows-process-resource-collector.ts | 2 +- src/main/minimax/minimax-cookie-store.ts | 2 +- .../native-chat/transcript-tail-reader.ts | 2 +- .../network/macos-tailscale-dns-diagnostic.ts | 2 +- src/main/observability/bundle.test.ts | 2 +- src/main/observability/redactor.test.ts | 2 +- src/main/observability/redactor.ts | 2 +- src/main/persistence.ts | 13 +- src/main/pi/legacy-omp-overlay-migration.ts | 2 +- src/main/plugins/plugin-dev-watcher.ts | 2 +- src/main/plugins/plugin-host-runtime.ts | 8 +- .../plugins/plugin-worker-output-buffer.ts | 10 +- src/main/plugins/plugin-worker-slot-pool.ts | 2 +- .../windows-foreground-process-rows.ts | 2 +- src/main/pty/posix-pty-foreground-group.ts | 2 +- .../rate-limits/minimax-request-context.ts | 2 +- .../rate-limits/opencode-go-usage-fetcher.ts | 2 +- src/main/runtime/device-registry.ts | 8 +- src/main/runtime/fetch-remote-cache.test.ts | 2 +- src/main/runtime/linear-save-issue.test.ts | 2 +- src/main/runtime/orca-runtime.ts | 23 ++- src/main/runtime/orchestration/db.ts | 6 +- .../orchestration/setup-completion-signal.ts | 2 +- src/main/runtime/rpc/core.ts | 4 +- src/main/runtime/rpc/methods/client-ui.ts | 2 +- .../rpc/methods/linear-agent-access.test.ts | 8 +- .../linear-agent-project-access.test.ts | 2 +- src/main/runtime/rpc/methods/native-chat.ts | 4 +- .../rpc/methods/worktree-schemas.test.ts | 4 +- .../rpc/orchestration-legacy-compatibility.ts | 2 +- .../rpc/orchestration-legacy-lifecycle.ts | 2 +- .../runtime/rpc/orchestration-legacy-mail.ts | 4 +- .../rpc/orchestration-legacy-question.ts | 2 +- .../rpc/orchestration-mutation-executor.ts | 2 +- .../rpc/orchestration-mutation-ledger.test.ts | 2 +- src/main/runtime/runtime-rpc.test.ts | 2 +- src/main/runtime/runtime-rpc.ts | 6 +- .../runtime/windows-firewall-remote-scope.ts | 6 +- ...workspace-session-failed-write-rollback.ts | 2 +- src/main/source-control/forge-provider.ts | 2 +- .../stacked-hosted-review-creation.ts | 3 +- .../ssh/sftp-namespace-resolution.test.ts | 5 +- src/main/ssh/ssh-channel-multiplexer.ts | 2 +- src/main/ssh/ssh-relay-session.ts | 4 +- .../ssh/ssh-remote-node-toolchain-probe.ts | 2 +- src/main/startup/hydrate-shell-path.ts | 4 +- src/main/warp-themes/discovery.ts | 2 +- src/main/window/createMainWindow.ts | 2 +- src/preload/index.ts | 5 +- src/relay/agent-hook-server.ts | 4 +- src/relay/ai-vault-service-client.ts | 4 +- src/relay/dispatcher-client-writer.test.ts | 2 +- src/relay/external-automations-handler.ts | 4 +- src/relay/git-handler-utils.ts | 2 +- src/relay/pty-handler.ts | 8 +- src/relay/relay-watcher-event-emitter.test.ts | 2 +- src/relay/relay.ts | 2 +- src/renderer/src/App.tsx | 21 ++- .../src/components/GitHubItemDialog.tsx | 2 +- .../src/components/PullRequestPage.tsx | 2 +- src/renderer/src/components/TaskPage.tsx | 2 +- .../src/components/WorktreeJumpPalette.tsx | 4 +- .../automations/AutomationsPage.tsx | 4 +- .../components/browser-pane/BrowserPane.tsx | 2 +- .../browser-address-bar-suggestions.ts | 2 +- .../context-menu-positioning.test.ts | 10 +- .../contextual-tours/contextual-tour-gate.ts | 2 +- .../dashboard-agent-status-patch.ts | 2 +- .../editor/CheckRunDetailsPanel.tsx | 4 +- .../components/editor/CombinedDiffViewer.tsx | 5 +- .../src/components/editor/IpynbViewer.tsx | 2 +- .../src/components/editor/editor-autosave.ts | 2 +- .../editor/editor-panel-draft-selector.ts | 2 +- .../editor/monaco-context-menu-paste.ts | 2 +- .../editor/monaco-markdown-doc-completions.ts | 2 +- .../rich-markdown-slash-command-catalog.tsx | 1 - .../rich-markdown-terminal-path-paste.ts | 2 +- .../src/components/editor/useLocalImageSrc.ts | 2 +- .../floating-terminal-panel-bounds.ts | 2 +- .../floating-terminal-trigger-position.ts | 2 +- .../claude-model-switch-confirmation.ts | 4 +- .../claude-terminal-session-options.ts | 6 +- .../native-chat-pending-occurrence.ts | 2 +- .../native-chat/native-chat-pending.ts | 6 +- .../project-combobox-matching.ts | 6 +- .../use-jira-url-source.test.tsx | 2 +- .../new-workspace/use-jira-url-source.ts | 2 +- .../checks-panel-review-creation.ts | 2 +- ...source-control-text-generation-defaults.ts | 2 +- .../src/components/settings/AccountsPane.tsx | 4 +- .../components/settings/SettingsConstants.ts | 2 +- .../settings/keybinding-override-edits.ts | 2 +- .../repository-source-control-ai-draft.ts | 2 +- .../sidebar/worktree-lineage-projection.ts | 2 +- .../sidebar/worktree-list-groups.ts | 4 +- .../worktree-title-derived-agent-rows.ts | 2 +- .../SourceControlActionVariableChips.tsx | 2 +- .../status-bar/mergeSnapshotAndSessions.ts | 4 +- .../tab-create-entry-url-classification.ts | 6 +- .../tab-bar/terminal-tab-activity-status.ts | 6 +- ...page-github-work-item-authority-refresh.ts | 2 +- ...e-github-work-item-mutation-composition.ts | 2 +- ...page-github-work-item-mutation-registry.ts | 2 +- ...-page-github-work-item-quiet-revalidate.ts | 6 +- .../task-page-linear-in-orca-issues.ts | 2 +- .../agent-process-inspection-queue.ts | 2 +- .../terminal-agent-session-fork.ts | 4 +- .../terminal-ime-linux-candidate-state.ts | 2 +- .../terminal-layout-leaf-detach.ts | 2 +- .../terminal-pane/terminal-layout-leaf-ids.ts | 4 +- .../terminal-layout-pty-ownership.ts | 6 +- .../terminal-paste-diagnostics.ts | 2 +- .../use-terminal-pane-global-effects.ts | 22 +-- .../workspace-cleanup-background-removal.ts | 2 +- ...pace-cleanup-post-batch-late-settlement.ts | 2 +- ...eanup-skipped-ancestor-reclassification.ts | 2 +- ...ssh-reconnect-coordinator-stabilization.ts | 2 +- .../src/lib/agent-hibernation-planner.ts | 5 +- src/renderer/src/lib/agent-status.ts | 4 +- src/renderer/src/lib/crash-diagnostics.ts | 2 +- .../src/lib/feature-education-telemetry.ts | 6 +- .../src/lib/markdown-document-templates.ts | 2 +- src/renderer/src/lib/native-chat-telemetry.ts | 2 +- src/renderer/src/lib/pane-manager/pane-fit.ts | 1 + .../pane-manager/pane-identity-registry.ts | 6 +- .../src/lib/pane-manager/pane-manager.ts | 2 +- .../pane-tree-reparent-frame.test.ts | 2 +- .../src/lib/runtime-pane-title-leaf-id.ts | 2 +- src/renderer/src/lib/setup-script-prompt.ts | 2 +- .../lib/simulator-pane-shutdown-scheduler.ts | 2 +- .../source-control-launch-agent-selection.ts | 8 +- .../lib/terminal-contrast-correction.test.ts | 2 +- .../src/lib/terminal-layout-equality.ts | 4 +- .../src/lib/terminal-quick-command-search.ts | 4 +- .../src/lib/terminal-themes/shared.ts | 2 +- .../src/lib/terminal-title-contrast.ts | 2 +- src/renderer/src/lib/ui-zoom.ts | 4 +- .../src/lib/window-visibility-interval.ts | 2 +- .../src/lib/workspace-emoji-shortcodes.ts | 2 +- .../src/runtime/web-session-tabs-sync.test.ts | 2 +- .../src/runtime/web-session-tabs-sync.ts | 3 +- .../store/running-terminal-close-confirm.ts | 2 +- src/renderer/src/store/slices/browser.ts | 2 +- src/renderer/src/store/slices/diffComments.ts | 2 +- src/renderer/src/store/slices/editor.ts | 2 +- .../src/store/slices/repo-host-identity.ts | 2 +- .../store/slices/repo-identity-reconcile.ts | 6 +- src/renderer/src/store/slices/repos.ts | 4 +- .../src/store/slices/ssh-target-cleanup.ts | 20 +-- .../src/store/slices/worktree-helpers.ts | 4 +- .../src/store/slices/worktree-nav-history.ts | 2 +- src/renderer/src/store/slices/worktrees.ts | 13 +- .../web/web-clipboard-copy-fallback.test.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 5 +- src/shared/agent-hook-listener.ts | 2 +- src/shared/automation-schedules.ts | 2 +- src/shared/browser-page-zoom.ts | 2 +- src/shared/external-worktree-visibility.ts | 2 +- src/shared/gitlab-types.ts | 2 +- src/shared/jira-types.ts | 2 +- src/shared/linear-agent-access.ts | 4 +- src/shared/linear-agent-result-types.ts | 8 +- src/shared/linear-issue-view-resume-state.ts | 4 +- src/shared/linear-links.ts | 2 +- src/shared/linear-mcp-issue-list.ts | 4 +- src/shared/mcp-config.ts | 2 +- src/shared/mcp-server-inspection.ts | 2 +- .../native-chat-image-transcript-markers.ts | 2 +- .../native-chat-session-option-commands.ts | 2 +- src/shared/native-chat-tool-summary.ts | 2 +- src/shared/persisted-ui-equality.ts | 2 +- .../remote-runtime-shared-control-ready.ts | 4 +- .../setup-script-import-codex-environment.ts | 2 +- src/shared/setup-script-imports.ts | 4 +- src/shared/source-control-ai-actions.ts | 2 +- src/shared/source-control-ai.ts | 10 +- src/shared/terminal-file-link-conformance.ts | 2 +- src/shared/text-search.ts | 2 +- src/shared/tui-agent-config.ts | 2 +- src/shared/tui-agent-launch-defaults.ts | 4 +- src/shared/types.ts | 6 +- ...pace-session-schema.sleeping-agent.test.ts | 9 +- .../e2e/chinese-ime-chat-input-repro.spec.ts | 2 +- .../e2e/codex-composer-echo-latency-probe.ts | 2 +- .../daemon-generation-legacy-close-client.ts | 2 +- .../daemon-generation-reconnect-client.ts | 2 +- .../fixtures/visible-tui-scroll-fixture.cjs | 2 +- .../completed-worker-retirement-fixture.ts | 2 +- ...an-ime-terminal-shift-enter-commit.spec.ts | 2 +- ...mote-agent-session-focus-authority.spec.ts | 2 +- ...l-link-hover-after-worktree-return.spec.ts | 2 +- tests/e2e/terminal-paste-ownership.spec.ts | 4 +- .../tools/win-crash-survival-e2e/cli-args.mjs | 2 +- tests/tools/win-update-e2e/cli-args.mjs | 2 +- .../tools/win-update-e2e/daemon-processes.mjs | 4 +- 280 files changed, 530 insertions(+), 571 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f679f17d52..97a2b68701a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -56,6 +56,7 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "typescript/array-type": "error", + "typescript/consistent-generic-constructors": "error", "typescript/consistent-indexed-object-style": "error", "typescript/consistent-type-assertions": "error", "typescript/consistent-type-definitions": ["error", "type"], @@ -68,12 +69,16 @@ "typescript/prefer-function-type": "error", "typescript/prefer-includes": "error", "typescript/prefer-optional-chain": "error", + "typescript/prefer-ts-expect-error": "error", "typescript/switch-exhaustiveness-check": [ "error", { "allowDefaultCaseForExhaustiveSwitch": false } ], "curly": "error", "no-unneeded-ternary": "error", + "no-else-return": "error", + "prefer-exponentiation-operator": "error", + "prefer-object-has-own": "error", "no-restricted-imports": [ "error", { @@ -90,11 +95,16 @@ "no-useless-return": "error", "prefer-template": "error", "unicorn/consistent-empty-array-spread": "error", + "unicorn/consistent-existence-index-check": "error", + "unicorn/empty-brace-spaces": "error", "unicorn/error-message": "error", "unicorn/no-array-fill-with-reference-type": "warn", "unicorn/no-array-reverse": "error", "unicorn/no-instanceof-builtins": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-unnecessary-array-splice-count": "error", "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-zero-fractions": "error", "unicorn/prefer-array-find": "error", "unicorn/prefer-array-flat-map": "warn", "unicorn/prefer-array-index-of": "error", diff --git a/config/electron-vite-target.config.ts b/config/electron-vite-target.config.ts index 60651dd6590..490fa654f5b 100644 --- a/config/electron-vite-target.config.ts +++ b/config/electron-vite-target.config.ts @@ -8,7 +8,7 @@ const configByTarget = { renderer: { renderer: electronViteConfig.renderer } } -if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) { +if (!target || !Object.hasOwn(configByTarget, target)) { throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? ''}`) } diff --git a/config/oxlint-code-quality-native-plugins.json b/config/oxlint-code-quality-native-plugins.json index 5d9cd118e74..5f2fb031a51 100644 --- a/config/oxlint-code-quality-native-plugins.json +++ b/config/oxlint-code-quality-native-plugins.json @@ -16,6 +16,7 @@ "import/namespace": "warn", "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", + "import/no-empty-named-blocks": "warn", "import/no-self-import": "warn" }, "overrides": [ diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 4c0e01fa748..8ae3cbab60e 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -12,6 +12,7 @@ }, "rules": { "typescript/await-thenable": "warn", + "typescript/no-redundant-type-constituents": "warn", "typescript/restrict-plus-operands": "warn", "typescript/restrict-template-expressions": "warn", "typescript/switch-exhaustiveness-check": [ diff --git a/config/scripts/build-notification-status-macos.mjs b/config/scripts/build-notification-status-macos.mjs index e8150e32b6f..41d4c84a4af 100644 --- a/config/scripts/build-notification-status-macos.mjs +++ b/config/scripts/build-notification-status-macos.mjs @@ -79,7 +79,7 @@ try { function readArg(name) { const index = args.indexOf(name) - return index >= 0 ? args[index + 1] : undefined + return index !== -1 ? args[index + 1] : undefined } function embeddedInfoPlist(identifier) { diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 6f9b86bdb4b..27c17b3d15c 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -56,5 +56,5 @@ function findFrameworkCompiler(env) { function readArg(name) { const index = process.argv.indexOf(name) - return index >= 0 ? process.argv[index + 1] : undefined + return index !== -1 ? process.argv[index + 1] : undefined } diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index 725f17fdf4c..2a3234d3d9c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -11,7 +11,7 @@ function source(path) { function sourceBetween(contents, startMarker, endMarker) { const start = contents.indexOf(startMarker) const end = contents.indexOf(endMarker, start + startMarker.length) - if (start < 0 || end < 0) { + if (start === -1 || end === -1) { throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`) } return contents.slice(start, end) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index f59f4a80f88..818b380fc2f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => { }) it('rejects prototype properties as build targets', () => { - expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)') + // Own-property check only: an inherited key like `constructor` must not select a build target. + expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)') }) it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 98b064cd30d..44261e968a9 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -45,7 +45,7 @@ function readRuntimeArg() { } const runtimeIndex = process.argv.indexOf('--runtime') - if (runtimeIndex >= 0) { + if (runtimeIndex !== -1) { return process.argv[runtimeIndex + 1] } diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index 899cb6436df..226ea74c4fb 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -227,7 +227,7 @@ function readGitBlobs(objectShas) { let offset = 0 for (const requestedSha of uniqueShas) { const headerEnd = output.indexOf(10, offset) - if (headerEnd < 0) { + if (headerEnd === -1) { throw new Error(`Missing git cat-file header for ${requestedSha}`) } const header = output.subarray(offset, headerEnd).toString('utf8') @@ -648,8 +648,8 @@ async function main() { const argv = process.argv.slice(2) const rebuildFromTags = argv.includes('--rebuild-from-tags') const releaseIndex = argv.indexOf('--release') - const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null - if (releaseIndex >= 0 && !releaseVersion) { + const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null + if (releaseIndex !== -1 && !releaseVersion) { throw new Error('--release requires a version argument, e.g. --release 1.4.160') } diff --git a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs index 5f4160600cc..76555d4e78e 100644 --- a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs +++ b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs @@ -30,8 +30,8 @@ const recoveryEnd = runtimeSource.indexOf( ) const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd) if ( - recoveryStart < 0 || - recoveryEnd < 0 || + recoveryStart === -1 || + recoveryEnd === -1 || !recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') || recoverySource.includes('flushOrThrow()') ) { diff --git a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs index 8724cf8faf4..b6f1d9571a5 100644 --- a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs +++ b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs @@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) { const onData = (chunk) => { buffer += chunk const newline = buffer.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return } let response diff --git a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs index 86791745c10..5e350bb24f8 100644 --- a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs +++ b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs @@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } @@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index b27a1f945d9..784abfc4850 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } settled = true @@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } ready = true diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts index 63abd01e376..30edeb9802a 100644 --- a/config/scripts/remote-shared-control-retirement-probe.ts +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -5,7 +5,8 @@ import { resolveEnvironment, resolveEnvironmentPairingOffer } from '../../src/shared/runtime-environment-store' -import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types' +import type { MemorySnapshot } from '../../src/shared/types' +import type { RuntimeStatus } from '../../src/shared/runtime-types' async function main(): Promise { const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs index 4fadf6f3001..fb3d0040350 100644 --- a/config/scripts/run-ssh-staged-upload-reliability.mjs +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -17,8 +17,8 @@ const defaultFiles = [ const cliArguments = process.argv.slice(2) const powerShellFlag = cliArguments.indexOf('--powershell') const configuredPowerShell = - powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE -if (powerShellFlag >= 0 && !configuredPowerShell) { + powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE +if (powerShellFlag !== -1 && !configuredPowerShell) { console.error('--powershell requires an executable path') process.exit(2) } diff --git a/config/scripts/terminal-e2e-helpers.mjs b/config/scripts/terminal-e2e-helpers.mjs index 2f89f1a7f90..8b698416326 100644 --- a/config/scripts/terminal-e2e-helpers.mjs +++ b/config/scripts/terminal-e2e-helpers.mjs @@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) { const args = process.argv.slice(2) const portIdx = args.indexOf('--port') const cmdIdx = args.indexOf('--command') - const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444 - const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null + const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444 + const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null const term = new OrcaTerminal(port) console.log('Connecting to Orca on CDP port', port, '...') diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 079735c41ec..187b5aa3fc2 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -142,7 +142,7 @@ function readGeneratedScripts(home, minMtime) { const body = readFileSync(path, 'utf8') const captureIndex = body.indexOf('payload=$(cat)') const firstExitIndex = body.indexOf('exit 0') - if (captureIndex < 0 || firstExitIndex <= captureIndex) { + if (captureIndex === -1 || firstExitIndex <= captureIndex) { throw new Error([fileName, ' can exit before capturing stdin'].join('')) } return { body, fileName, path, source } diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index ba23a49cdd0..56efeb33521 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -489,11 +489,11 @@ function verifyNativeArgumentGuardrails() { function powerShellFunctionBody(source, name) { const start = source.indexOf(`function ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 @@ -513,11 +513,11 @@ function powerShellFunctionBody(source, name) { function swiftFunctionBody(source, name) { const start = source.indexOf(`func ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs index 00ea47ded0a..d29aaea2824 100644 --- a/config/scripts/wsl-git-shell-benchmark.mjs +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -175,7 +175,7 @@ async function main() { wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)]) ) const markerOffset = result.stdout.indexOf(outputMarker) - if (markerOffset < 0) { + if (markerOffset === -1) { throw new Error('Login shell did not emit the Git output marker') } result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker)) diff --git a/mobile/src/components/mobile-markdown-preview-html.ts b/mobile/src/components/mobile-markdown-preview-html.ts index 8b2d247f8f2..8bb5fca0934 100644 --- a/mobile/src/components/mobile-markdown-preview-html.ts +++ b/mobile/src/components/mobile-markdown-preview-html.ts @@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string { const valueStart = cursor if (quote) { const valueEnd = tag.indexOf(quote, cursor) - if (valueEnd < 0) { + if (valueEnd === -1) { return '' } cursor = valueEnd + 1 diff --git a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts index 90e3c3cc3cc..6eeca2aa612 100644 --- a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts +++ b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts @@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set { let cursor = 0 while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return names } const end = findMobileMarkdownMarkupTagEnd(value, start + 1) @@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { const closingTagNames = closingMarkupTagNames(value) while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return output + value.slice(cursor) } output += value.slice(cursor, start) @@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { !/[-:]/.test(name) && !knownMarkupTagNames.has(lowerName) && !closingTagNames.has(lowerName) - if (isNestedGeneric && nestedStart >= 0) { + if (isNestedGeneric && nestedStart !== -1) { output += value.slice(start, nestedStart) cursor = nestedStart continue diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index 71f26ca155b..8a3e795ac2d 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -132,7 +132,7 @@ export function getDirectoryCacheState( ): DirectoryState | undefined { // Why: repository paths are arbitrary object keys; inherited keys like // "constructor" must not masquerade as loaded directory state. - return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined + return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined } export function joinRelativePath(parentPath: string, name: string): string { diff --git a/mobile/src/hooks/use-active-worktree-scroll.ts b/mobile/src/hooks/use-active-worktree-scroll.ts index 7600f3ceafe..ff0441c85a8 100644 --- a/mobile/src/hooks/use-active-worktree-scroll.ts +++ b/mobile/src/hooks/use-active-worktree-scroll.ts @@ -37,7 +37,7 @@ export function useActiveWorktreeScroll( (worktreeId: string): boolean => { for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId) - if (itemIndex >= 0) { + if (itemIndex !== -1) { sectionListRef.current?.scrollToLocation({ sectionIndex, itemIndex, diff --git a/mobile/src/session/mobile-diff-review-screen-model.ts b/mobile/src/session/mobile-diff-review-screen-model.ts index 3dcaed35716..f8f1ceedf99 100644 --- a/mobile/src/session/mobile-diff-review-screen-model.ts +++ b/mobile/src/session/mobile-diff-review-screen-model.ts @@ -100,7 +100,7 @@ export function nextReviewIndexAfterMarkReviewed({ const wrappedIndex = filteredQueue.findIndex( (item) => item.key !== currentItemKey && !item.isReviewed ) - const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null + const targetIndex = nextIndex !== -1 ? nextIndex : wrappedIndex !== -1 ? wrappedIndex : null if (targetIndex === null) { return null } diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 360fd22587b..a6f599b7458 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -87,7 +87,7 @@ export function mergeLandedImagePreviewEchoes( const entries = Object.entries(previous[sessionKey] ?? {}) for (const preview of landed) { const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) - if (existingIndex >= 0) { + if (existingIndex !== -1) { entries.splice(existingIndex, 1) } entries.push([preview.messageId, preview.images]) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 99421ffd192..c32ac44ac77 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -182,7 +182,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } } let next: StoredHostProfile[] - if (index >= 0) { + if (index !== -1) { updatedExistingHost = true // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. next = hosts @@ -195,7 +195,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] } if (duplicateHostIds.size > 0) { - if (index < 0) { + if (index === -1) { // Why: process death between the early token write and metadata publication must leave cleanup discoverable. await recordHostCredentialCleanupIntent(stored.id) cleanupIntentRecordedBeforeMetadata = true @@ -304,7 +304,7 @@ export async function updateHostNameAndEndpoint( ): Promise { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((host) => host.id === hostId) - if (index < 0) { + if (index === -1) { throw new Error('Host not found') } const next = hosts.slice() @@ -321,7 +321,7 @@ export async function updateLastConnected(hostId: string): Promise { try { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((h) => h.id === hostId) - if (index < 0) { + if (index === -1) { return hosts } const next = hosts.slice() diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 7f1519eb5c9..4960fbec3f0 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -80,7 +80,7 @@ export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay const validated = MobileRelayHostOverlaySchema.parse(overlay) return mutateOverlays((overlays) => { const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) - if (index < 0) { + if (index === -1) { return [...overlays, validated] } const next = overlays.slice() diff --git a/mobile/src/transport/rpc-response-shape.ts b/mobile/src/transport/rpc-response-shape.ts index 54903f5f3e1..61ecaf57362 100644 --- a/mobile/src/transport/rpc-response-shape.ts +++ b/mobile/src/transport/rpc-response-shape.ts @@ -13,7 +13,7 @@ export function isRpcResponse(value: unknown): value is RpcResponse { return false } if (response.ok === true) { - return Object.prototype.hasOwnProperty.call(response, 'result') + return Object.hasOwn(response, 'result') } return ( response.ok === false && diff --git a/package.json b/package.json index 83cd7597e5f..6a0e4f33814 100644 --- a/package.json +++ b/package.json @@ -217,7 +217,7 @@ "mermaid": "^11.16.1", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", - "oxlint": "^1.75.0", + "oxlint": "^1.77.0", "oxlint-plugin-react-doctor": "0.9.1", "oxlint-tsgolint": "7.0.2001", "pdfjs-dist": "^6.2.108", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1872f9860e8..57885d16e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,8 +294,8 @@ importers: specifier: ^0.52.0 version: 0.52.0 oxlint: - specifier: ^1.75.0 - version: 1.75.0(oxlint-tsgolint@7.0.2001) + specifier: ^1.77.0 + version: 1.77.0(oxlint-tsgolint@7.0.2001) oxlint-plugin-react-doctor: specifier: 0.9.1 version: 0.9.1 @@ -1488,124 +1488,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.75.0': - resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.75.0': - resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.75.0': - resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.75.0': - resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.75.0': - resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': - resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.75.0': - resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.75.0': - resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.75.0': - resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.75.0': - resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.75.0': - resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.75.0': - resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.75.0': - resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.75.0': - resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.75.0': - resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.75.0': - resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.75.0': - resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.75.0': - resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.75.0': - resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5541,8 +5541,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.75.0: - resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7809,61 +7809,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.75.0': + '@oxlint/binding-android-arm-eabi@1.77.0': optional: true - '@oxlint/binding-android-arm64@1.75.0': + '@oxlint/binding-android-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-arm64@1.75.0': + '@oxlint/binding-darwin-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-x64@1.75.0': + '@oxlint/binding-darwin-x64@1.77.0': optional: true - '@oxlint/binding-freebsd-x64@1.75.0': + '@oxlint/binding-freebsd-x64@1.77.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.75.0': + '@oxlint/binding-linux-arm-musleabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.75.0': + '@oxlint/binding-linux-arm64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.75.0': + '@oxlint/binding-linux-arm64-musl@1.77.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.75.0': + '@oxlint/binding-linux-ppc64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.75.0': + '@oxlint/binding-linux-riscv64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.75.0': + '@oxlint/binding-linux-riscv64-musl@1.77.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.75.0': + '@oxlint/binding-linux-s390x-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.75.0': + '@oxlint/binding-linux-x64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-musl@1.75.0': + '@oxlint/binding-linux-x64-musl@1.77.0': optional: true - '@oxlint/binding-openharmony-arm64@1.75.0': + '@oxlint/binding-openharmony-arm64@1.77.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.75.0': + '@oxlint/binding-win32-arm64-msvc@1.77.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.75.0': + '@oxlint/binding-win32-ia32-msvc@1.77.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.75.0': + '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -12174,27 +12174,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.75.0(oxlint-tsgolint@7.0.2001): + oxlint@1.77.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.75.0 - '@oxlint/binding-android-arm64': 1.75.0 - '@oxlint/binding-darwin-arm64': 1.75.0 - '@oxlint/binding-darwin-x64': 1.75.0 - '@oxlint/binding-freebsd-x64': 1.75.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 - '@oxlint/binding-linux-arm-musleabihf': 1.75.0 - '@oxlint/binding-linux-arm64-gnu': 1.75.0 - '@oxlint/binding-linux-arm64-musl': 1.75.0 - '@oxlint/binding-linux-ppc64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-musl': 1.75.0 - '@oxlint/binding-linux-s390x-gnu': 1.75.0 - '@oxlint/binding-linux-x64-gnu': 1.75.0 - '@oxlint/binding-linux-x64-musl': 1.75.0 - '@oxlint/binding-openharmony-arm64': 1.75.0 - '@oxlint/binding-win32-arm64-msvc': 1.75.0 - '@oxlint/binding-win32-ia32-msvc': 1.75.0 - '@oxlint/binding-win32-x64-msvc': 1.75.0 + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 oxlint-tsgolint: 7.0.2001 p-cancelable@2.1.1: {} diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 7f60956e6e4..51f299595cd 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -32,7 +32,7 @@ function hasCode(error: unknown, code: string): boolean { export function parseLinuxStartTicks(statLine: string): string | null { const commandEnd = statLine.lastIndexOf(')') - if (commandEnd < 0) { + if (commandEnd === -1) { return null } // Field 22 is index 19 after removing pid and the parenthesized command. diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index cfcee4c2240..4f46dc643c5 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -130,7 +130,7 @@ export class AiVaultScannerServiceClient { continue } const index = this.queue.findIndex((call) => call.lane === lane) - if (index < 0) { + if (index === -1) { continue } const call = this.queue.splice(index, 1)[0]! @@ -257,7 +257,7 @@ export class AiVaultScannerServiceClient { call.cancelled = true call.reject(createAiVaultScanCancelledError()) const queuedIndex = this.queue.indexOf(call) - if (queuedIndex >= 0) { + if (queuedIndex !== -1) { this.queue.splice(queuedIndex, 1) clearAiVaultServiceCall(call) this.pump() diff --git a/src/main/ai-vault/session-scanner-worker-client.ts b/src/main/ai-vault/session-scanner-worker-client.ts index 6b780c5086d..70e75c4f982 100644 --- a/src/main/ai-vault/session-scanner-worker-client.ts +++ b/src/main/ai-vault/session-scanner-worker-client.ts @@ -176,7 +176,7 @@ export class AiVaultScannerWorkerClient { return } const index = this.queue.indexOf(call) - if (index >= 0) { + if (index !== -1) { this.queue.splice(index, 1) this.clearCall(call) } diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index db1e054f7e8..581b7f998d1 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -470,7 +470,7 @@ function findMatchingSessionRunIndex( (sessionRun, index) => !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey ) - if (exactMatchIndex >= 0) { + if (exactMatchIndex !== -1) { return exactMatchIndex } @@ -798,7 +798,7 @@ function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { } } -function readHermesSessionDbRunById(jobId: string, runId: string): unknown | null { +function readHermesSessionDbRunById(jobId: string, runId: string): unknown { if (!existsSync(HERMES_STATE_DB)) { return null } diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index b9f5a9bd92d..125949f3a50 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -74,8 +74,8 @@ type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => vo } function mockBrowserManager( - tabs: Map = new Map([['tab-1', 100]]), - worktrees: Map = new Map(), + tabs = new Map([['tab-1', 100]]), + worktrees = new Map(), overrides: Partial = {} ): BrowserManager { return { diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index bffb12cfa02..6d5f1661703 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -911,7 +911,7 @@ export function buildChromiumCookieInsertParams( return decryptedValue } - const sourceHasColumn = Object.prototype.hasOwnProperty.call(sourceRow, column.name) + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null if (sourceValue !== null) { return sourceValue diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index f5f9bb7a7b2..f27a861a45c 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -24,7 +24,7 @@ function seedMeta(fsState: FsState, meta: unknown): void { function installModuleMocks( fsState: FsState, - copyFailures: Set = new Set() + copyFailures = new Set() ): { sessionFromPartitionMock: ReturnType setupClientHintsOverrideMock: ReturnType diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts index 4f04065e36c..60da5b1e297 100644 --- a/src/main/browser/browser-webauthn-access.ts +++ b/src/main/browser/browser-webauthn-access.ts @@ -15,7 +15,7 @@ function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean { } } -function isFidoHidDevice(device: Electron.HIDDevice | unknown): device is Electron.HIDDevice { +function isFidoHidDevice(device: unknown): device is Electron.HIDDevice { if (!device || typeof device !== 'object') { return false } diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index b3b1fae9401..b3b836d07b0 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -212,7 +212,7 @@ function createMockGuest( removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = debuggerListeners.get(event) ?? [] const idx = handlers.indexOf(handler) - if (idx >= 0) { + if (idx !== -1) { handlers.splice(idx, 1) } }), diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index f6a1684e19c..3dee00a871b 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -379,7 +379,7 @@ export class WslCliInstaller { private async readCommandFile( distro: string, commandPath: string - ): Promise { + ): Promise<(string & {}) | 'not_file' | null> { const output = await this.run( distro, [ diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts index d1a84a42f52..99d3faff5d4 100644 --- a/src/main/cli/wsl-cli-registration-registry.ts +++ b/src/main/cli/wsl-cli-registration-registry.ts @@ -138,7 +138,7 @@ async function readState(userDataPath: string): Promise normalizeWslDistroKey(entry) === key) - if (existingIndex < 0) { + if (existingIndex === -1) { return [...distros, distro.trim()] } return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry)) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index b55c9b50315..eef834af323 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1311,7 +1311,7 @@ export class CodexAccountService { const managedRootMarker = '/.local/share/orca/codex-accounts/' const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) - if (markerIndex < 0) { + if (markerIndex === -1) { return null } const wslHome = wslInfo.linuxPath.slice(0, markerIndex) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index 700359d4f37..b964f5ba518 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -109,7 +109,7 @@ function getLaunchEnvValue( launchEnv: NodeJS.ProcessEnv, key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' ): string | undefined { - return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] + return Object.hasOwn(launchEnv, key) ? launchEnv[key] : process.env[key] } function normalizePathForComparison(value: string): string { diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 75d91d7ab34..2f0aa5429c6 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -140,7 +140,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { trustedCodexHomes: [homePath], ...withoutHomeRanking, fileIsRegular: () => true, - listSessionFiles: async function* (): AsyncIterable { + async *listSessionFiles(): AsyncIterable { yield extendedEntry } }) @@ -153,7 +153,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { trustedCodexHomes: [homePath], ...withoutHomeRanking, fileIsRegular: () => true, - listSessionFiles: async function* (): AsyncIterable { + async *listSessionFiles(): AsyncIterable { yield `\\\\.\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl` } }) diff --git a/src/main/computer/macos-native-provider-transport.ts b/src/main/computer/macos-native-provider-transport.ts index 54c4cf773aa..1048ce5a94c 100644 --- a/src/main/computer/macos-native-provider-transport.ts +++ b/src/main/computer/macos-native-provider-transport.ts @@ -52,7 +52,7 @@ export function consumeNativeProviderLines( let remaining = buffer while (true) { const newline = remaining.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return remaining } const line = remaining.slice(0, newline) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 01d9ffc97c5..f5c88935998 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -237,7 +237,7 @@ async function probeWindowsProcess( export function parseLinuxProcessState(statLine: string): string | null { const commandEnd = statLine.lastIndexOf(')') - if (commandEnd < 0) { + if (commandEnd === -1) { return null } return ( diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 51d9d0913a2..731da9eefd5 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -145,7 +145,7 @@ export class DaemonServer { private transportSockets = new Set() private createOrAttachInFlight = 0 private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' - private initialAdoptionTimer: unknown | null = null + private initialAdoptionTimer: unknown = null private initialAdoptionDeadlineMs: number | null = null private retirementRequested = false private shutdownPromise: Promise | null = null diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index 1f529ff3e00..e2df486e38f 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -66,9 +66,9 @@ export class MacosLoginSessionDeathWatch { private probeInFlight = false private stopped = false private retired = false - private scheduledProbeTimer: unknown | null = null + private scheduledProbeTimer: unknown = null private scheduledProbeAtMs: number | null = null - private ptyExitDebounceTimer: unknown | null = null + private ptyExitDebounceTimer: unknown = null private pendingProbeTrigger: string | null = null private probeAbortController: AbortController | null = null diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index 1ba58393721..352927a5dfd 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -39,9 +39,8 @@ export class TerminalSessionTeardown { } if (immediate) { return this.forceKillPlainShellSession(sessionId, session) - } else { - session.kill() } + session.kill() } /** diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index ddd04bf11dc..decca3d3f99 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -57,7 +57,7 @@ function isValidColorIndex(value: number): boolean { function relativeLuminance([r, g, b]: TerminalViewRgb): number { const linear = (channel: number): number => { const c = channel / 255 - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 } diff --git a/src/main/daemon/xterm-env-polyfill.ts b/src/main/daemon/xterm-env-polyfill.ts index 9ab12627299..27605b8fa9a 100644 --- a/src/main/daemon/xterm-env-polyfill.ts +++ b/src/main/daemon/xterm-env-polyfill.ts @@ -1,6 +1,6 @@ // @xterm/headless checks for `window` to detect browser vs node environment. // In ELECTRON_RUN_AS_NODE mode, `window` is undefined. This polyfill must be // imported before any @xterm/headless import. -if (typeof globalThis.window === 'undefined') { +if (globalThis.window === undefined) { ;(globalThis as Record).window = globalThis } diff --git a/src/main/emulator/android/adb-devices.ts b/src/main/emulator/android/adb-devices.ts index 692bbb4d0fb..7264a825310 100644 --- a/src/main/emulator/android/adb-devices.ts +++ b/src/main/emulator/android/adb-devices.ts @@ -8,7 +8,7 @@ export type AndroidAdbDeviceState = | 'bootloader' | 'recovery' | 'no permissions' - | string + | (string & {}) export type AndroidAdbDevice = { serial: string diff --git a/src/main/emulator/mjpeg-frame-parser.ts b/src/main/emulator/mjpeg-frame-parser.ts index 841bc6a8c91..90733e2c1fe 100644 --- a/src/main/emulator/mjpeg-frame-parser.ts +++ b/src/main/emulator/mjpeg-frame-parser.ts @@ -32,7 +32,7 @@ export function extractJpegFrames( while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -41,7 +41,7 @@ export function extractJpegFrames( } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { return { frames, pending: trimPendingBuffer(cursor, maxPendingBytes) } } diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index e609d90a981..26291ec5d39 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -932,7 +932,7 @@ export function parseAndFilterSearchRefDetails( .filter((line) => line.length > 0) .map((line) => { const nul = line.indexOf('\0') - if (nul < 0) { + if (nul === -1) { // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. return null } diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index f67d696a37b..7df86a44a94 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -26,7 +26,7 @@ function parseProbe(stdout: string): WslGitReadEnvironment | null { const gitPath = fields[markerIndex + 2] ?? '' const home = fields[markerIndex + 3] ?? '' if ( - markerIndex < 0 || + markerIndex === -1 || !path.includes('/') || path.length > 32_768 || !gitPath.startsWith('/') || diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts index 357f78bcbc6..e34752df6d9 100644 --- a/src/main/github/stacked-pr-creation.ts +++ b/src/main/github/stacked-pr-creation.ts @@ -209,7 +209,7 @@ function registeredStackNumber( ) // Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already // registered" whenever the current PR heads a stack the parent has left. - if (parentPosition < 0) { + if (parentPosition === -1) { return null } return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 8df189a4abb..bbccfb41e93 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -6,7 +6,7 @@ import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { listEnvironments } from '../../shared/runtime-environment-store' import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, removeHandlerMock, diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index fcd004dcd06..bb143987c96 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -645,7 +645,7 @@ describe('fs:importExternalPaths', () => { ) const mib = 1024 * 1024 const regularSize = 25 * mib - const overflowSize = 1 * mib + const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) lstatMock.mockImplementation(async (p: string) => { @@ -683,7 +683,7 @@ describe('fs:importExternalPaths', () => { ) openMock.mockImplementation(async (p: string) => { const fileIndex = filePaths.indexOf(p) - if (fileIndex >= 0 && fileIndex < filePaths.length - 1) { + if (fileIndex !== -1 && fileIndex < filePaths.length - 1) { return { stat: vi.fn().mockResolvedValue({ size: regularSize, diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9e0a259d2d3..6c677c73d6d 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -20,7 +20,7 @@ const { toWindowsWslPathMock: vi.fn((value: string) => value) })) -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() vi.mock('electron', () => ({ ipcMain: { diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts index f557ebca379..7d27d4ca780 100644 --- a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts @@ -22,7 +22,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> const WORKTREE_PATH = '/home/me/repo' const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 80d45c90e43..e605cd6167c 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -38,7 +38,7 @@ import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher large batches', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 0e15ad43fc7..eb161c1ba30 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -47,7 +47,7 @@ import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import { subscribeViaWatcherProcess } from './parcel-watcher-process' import { WatcherProcessFailure } from './parcel-watcher-process-failure' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher unsubscribe cleanup', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index 41db2d35e52..670b7eb2af3 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -28,7 +28,7 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> function fillWatcherChildCapacity(): (() => void)[] { return Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => { diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index 34f2bcc91e1..a813be69e28 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -26,7 +26,7 @@ vi.mock('electron', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type FsChangedCall = { worktreePath: string; events: { kind: string; absolutePath: string }[] } diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts index 6d964c2b2d4..8666a40c906 100644 --- a/src/main/ipc/filesystem-watcher-remote-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -23,7 +23,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type WatchCallback = (events: FsChangeEvent[]) => void const WORKTREE_PATH = '/home/me/repo' diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index 6a67f3a8fb7..60e25db9040 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -19,7 +19,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('remote filesystem watcher cancellation', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts index 869c8bebf45..0eecfbd82e9 100644 --- a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts @@ -29,7 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('remote filesystem watcher re-arm', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts index 52a48e9a06d..9eabdb8964e 100644 --- a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts +++ b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts @@ -52,7 +52,7 @@ import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import { subscribeViaWatcherProcess } from './parcel-watcher-process' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher removal deadline', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts index 8a08c5f07d5..31e87d7a4c9 100644 --- a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts +++ b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts @@ -28,7 +28,7 @@ import { restoreRemoteWatcherAfterFailedRemoval } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type TerminalErrorHandler = (error: Error) => void const WORKTREE_PATH = '/home/me/repo' diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts index d629d74dc02..3473caa8a73 100644 --- a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -30,7 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' import { stat } from 'node:fs/promises' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('filesystem watcher unwatchable root cache', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 1e9e3cf509a..6fff613e0d9 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -60,7 +60,7 @@ import { import { acquireWatcherRemovalGate } from './watcher-removal-gate' import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> /** Remote fs:changed rides the shared debounce window, so drain it before asserting sends. */ const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2c66ab9ad09..f96667751d2 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, showSaveDialogMock, diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index d68668038bd..cdffcb8b913 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ChildProcessWithoutNullStreams } from 'node:child_process' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { spawnMock, handleMock, resolveAuthorizedPathMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), handleMock: vi.fn((channel: string, handler: (event: unknown, args: unknown) => unknown) => { diff --git a/src/main/ipc/runtime-watcher-quarantine-queue.ts b/src/main/ipc/runtime-watcher-quarantine-queue.ts index e326b299943..bc2b4cf5fd4 100644 --- a/src/main/ipc/runtime-watcher-quarantine-queue.ts +++ b/src/main/ipc/runtime-watcher-quarantine-queue.ts @@ -33,7 +33,7 @@ export class RuntimeWatcherQuarantineQueue { } settled = true const index = this.waiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { this.waiters.splice(index, 1) } callback() diff --git a/src/main/ipc/ssh-pty-legacy-projection-record.ts b/src/main/ipc/ssh-pty-legacy-projection-record.ts index 45c666dc09a..6d9843d888c 100644 --- a/src/main/ipc/ssh-pty-legacy-projection-record.ts +++ b/src/main/ipc/ssh-pty-legacy-projection-record.ts @@ -117,7 +117,7 @@ export function reclaimProjectionRecord( return } const index = ids.indexOf(id) - if (index >= 0) { + if (index !== -1) { ids.splice(index, 1) } if (ids.length === 0) { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 59ffcfbc9a9..0c504c5f0a0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -177,7 +177,7 @@ export async function requestActiveSshAiVaultSessionList( targetId: string, params: SshAiVaultRelayListParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { +): Promise { if (isRuntimeOwnedSshTargetId(targetId)) { return null } @@ -192,7 +192,7 @@ export async function requestActiveSshAiVaultSessionTitles( targetId: string, params: SshAiVaultRelayTitleParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { +): Promise { if (isRuntimeOwnedSshTargetId(targetId)) { return null } diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index f5afe03628b..9c847b14ad8 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, removeHandlerMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted( () => ({ handleMock: vi.fn(), diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index a2ec6b0e990..7fb464f3c94 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -26,7 +26,7 @@ function isFileStat(value: GitDirectoryStat): boolean { function runtimeDirname(pathValue: string): string { const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '') const index = normalized.lastIndexOf('/') - if (index < 0) { + if (index === -1) { return '.' } if (index === 0) { diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index dae122943a3..86cc06e1420 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -74,7 +74,7 @@ async function snapshotGitCommon( previous?: GitCommonSnapshot, includePrimary = true, forceFullScan = false, - statusRefPaths: Set = new Set() + statusRefPaths = new Set() ): Promise { const worktreesDir = join(commonDirPath, 'worktrees') const [worktreesDirSignature, primarySignatures, statusRefSignatures] = await Promise.all([ diff --git a/src/main/jira/attachment-images.ts b/src/main/jira/attachment-images.ts index ce2e682b872..d1cfb2ab079 100644 --- a/src/main/jira/attachment-images.ts +++ b/src/main/jira/attachment-images.ts @@ -191,7 +191,7 @@ export function createMediaMarkdownResolver( } const index = queue.findIndex((entry) => entry.id === image.id) // Why: already-consumed images must not re-emit; fall through to positional pairing. - if (index < 0) { + if (index === -1) { return null } queue.splice(index, 1) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 70f45628461..cd3497fb2be 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -61,7 +61,7 @@ export function acquire(signal?: AbortSignal): Promise { signal, onAbort: () => { const index = queue.indexOf(entry) - if (index < 0) { + if (index === -1) { return } queue.splice(index, 1) diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 4d41a42ba87..7a357010406 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -221,7 +221,7 @@ function removeConflictingOverrides( for (const conflict of conflicts) { for (const actionId of conflict.actionIds) { - if (Object.prototype.hasOwnProperty.call(next, actionId)) { + if (Object.hasOwn(next, actionId)) { conflictingOverrides.add(actionId) } } @@ -346,8 +346,8 @@ export function seedLegacyTabSwitchBindings( // common + active-platform sections directly so the seed never replaces it. const toSeed = actionIds.filter( (actionId) => - !Object.prototype.hasOwnProperty.call(current.commonOverrides, actionId) && - !Object.prototype.hasOwnProperty.call(activePlatformOverrides, actionId) + !Object.hasOwn(current.commonOverrides, actionId) && + !Object.hasOwn(activePlatformOverrides, actionId) ) if (toSeed.length === 0) { return { seeded: false, snapshot: current } diff --git a/src/main/linear/issue-context-client.ts b/src/main/linear/issue-context-client.ts index 1f4863d235a..c9e5085f3bb 100644 --- a/src/main/linear/issue-context-client.ts +++ b/src/main/linear/issue-context-client.ts @@ -43,7 +43,7 @@ export type ResolvedIssue = { export async function searchLinearIssuesForAgents(args: { query: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const limit = clampLinearSearchLimit(args.limit) const workspaceId = resolveSearchWorkspaceId(args.workspaceId) @@ -144,14 +144,18 @@ function getExplicitClientEntries(workspaceId?: string): { } } -function resolveSearchWorkspaceId(workspaceId?: string | 'all'): string | 'all' | undefined { +function resolveSearchWorkspaceId( + workspaceId?: (string & {}) | 'all' +): (string & {}) | 'all' | undefined { if (!workspaceId || workspaceId === 'all') { return workspaceId } return resolveWorkspaceSelector({ workspaceId }, getConnectedWorkspaces())?.id ?? workspaceId } -function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | 'all'): void { +function throwIfExplicitWorkspaceHasConnectedAlternatives( + workspaceId?: (string & {}) | 'all' +): void { if (!workspaceId || workspaceId === 'all') { return } @@ -169,7 +173,7 @@ function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | export async function withLinearRead( entry: LinearClientForWorkspace, read: () => Promise, - selection?: string | 'all' + selection?: (string & {}) | 'all' ): Promise { void selection await acquire() @@ -206,7 +210,7 @@ async function readIssueWorkspace( async function readIssueWorkspaces( entries: LinearClientForWorkspace[], identifier: string, - selection: string | 'all', + selection: (string & {}) | 'all', initialFailures: WorkspaceReadFailure[] = [] ): Promise { if (selection !== 'all') { @@ -243,7 +247,7 @@ async function readSearchWorkspace( entry: LinearClientForWorkspace, query: string, limit: number, - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' ): Promise { const response = await withLinearRead( entry, @@ -269,7 +273,7 @@ async function readSearchWorkspaces( entries: LinearClientForWorkspace[], query: string, limit: number, - workspaceId?: string | 'all', + workspaceId?: (string & {}) | 'all', initialFailures: WorkspaceReadFailure[] = [] ): Promise<{ results: LinearSearchIssueSummary[][]; failures: WorkspaceReadFailure[] }> { if (workspaceId && workspaceId !== 'all') { diff --git a/src/main/linear/mcp-issue-list.ts b/src/main/linear/mcp-issue-list.ts index 3906442879e..09c5fff3336 100644 --- a/src/main/linear/mcp-issue-list.ts +++ b/src/main/linear/mcp-issue-list.ts @@ -106,7 +106,7 @@ export async function listMcpIssues( } } -function getIssueListEntries(workspaceId?: string | 'all'): { +function getIssueListEntries(workspaceId?: (string & {}) | 'all'): { entries: LinearClientForWorkspace[] failures: WorkspaceReadFailure[] } { diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts index d73ae95b8c9..527c4678fc9 100644 --- a/src/main/memory/windows-process-resource-collector.ts +++ b/src/main/memory/windows-process-resource-collector.ts @@ -286,7 +286,7 @@ export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResour function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { const processStart = path.lastIndexOf('\\Process(') const counterStart = path.lastIndexOf(')\\') - if (processStart < 0 || counterStart <= processStart + 9) { + if (processStart === -1 || counterStart <= processStart + 9) { return null } return { diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 3864f892bfb..1b98a1a81c6 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -33,7 +33,7 @@ function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null { } const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length) const separator = rest.indexOf(':') - if (separator < 0) { + if (separator === -1) { throw new Error('MiniMax session cookie could not be decrypted') } const kind = rest.slice(0, separator) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index c7b1527858a..fd590855ac7 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -202,7 +202,7 @@ async function findLastCompleteLineEnd( const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) - if (newline >= 0) { + if (newline !== -1) { return start + newline + 1 } cursor = start diff --git a/src/main/network/macos-tailscale-dns-diagnostic.ts b/src/main/network/macos-tailscale-dns-diagnostic.ts index 5ec5501525e..a4dc948c0bd 100644 --- a/src/main/network/macos-tailscale-dns-diagnostic.ts +++ b/src/main/network/macos-tailscale-dns-diagnostic.ts @@ -19,7 +19,7 @@ const NETWORK_LOOKUP_FAILURE_RE = function globalDnsSection(scutilOutput: string): string { const scopedStart = scutilOutput.indexOf('\nDNS configuration (for scoped queries)') - return scopedStart >= 0 ? scutilOutput.slice(0, scopedStart) : scutilOutput + return scopedStart !== -1 ? scutilOutput.slice(0, scopedStart) : scutilOutput } export function parseMacTailscaleDnsDiagnostic(scutilOutput: string): DnsDiagnostic | null { diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 347e1025893..3c6644a7d43 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -35,7 +35,7 @@ function makeSpan(overrides: Record = {}): Record): RedactableSpan { kind: 'internal', startTimeUnixNano: '1000', endTimeUnixNano: '2000', - durationMs: 1.0, + durationMs: 1, attributes: {}, events: [], exit: { _tag: 'Success' }, diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts index cd5570f8577..da8feb0a8b0 100644 --- a/src/main/observability/redactor.ts +++ b/src/main/observability/redactor.ts @@ -123,7 +123,7 @@ export function redactString(input: string): string { export function redactValue( value: unknown, mode: RedactorMode = 'client', - seen: WeakSet = new WeakSet() + seen = new WeakSet() ): unknown { if (value === null || value === undefined) { return value diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c15cab49aad..14565e4a698 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -673,11 +673,8 @@ function migrateTerminalScrollbackRows(settings: unknown): { needsSave: boolean } { const legacySettings = readLegacyTerminalScrollbackSettings(settings) - const hasRows = Object.prototype.hasOwnProperty.call(legacySettings, 'terminalScrollbackRows') - const hasLegacyBytes = Object.prototype.hasOwnProperty.call( - legacySettings, - 'terminalScrollbackBytes' - ) + const hasRows = Object.hasOwn(legacySettings, 'terminalScrollbackRows') + const hasLegacyBytes = Object.hasOwn(legacySettings, 'terminalScrollbackBytes') const rows = hasRows ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) @@ -1685,7 +1682,7 @@ type LayoutLeafNormalization = { function collectLayoutLeafCounts( node: TerminalPaneLayoutNode, - counts: Map = new Map() + counts = new Map() ): Map { if (node.type === 'leaf') { counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) @@ -7226,14 +7223,14 @@ export class Store { (entry) => entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) - const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined + const existing = existingIndex !== -1 ? this.state.sshRemotePtyLeases[existingIndex] : undefined const next: SshRemotePtyLease = { ...existing, ...normalizedLease, createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, updatedAt: normalizedLease.updatedAt ?? now } - if (existingIndex >= 0) { + if (existingIndex !== -1) { this.state.sshRemotePtyLeases[existingIndex] = next } else { this.state.sshRemotePtyLeases.push(next) diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts index 88baf78ea9f..74d6039fcb5 100644 --- a/src/main/pi/legacy-omp-overlay-migration.ts +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -146,7 +146,7 @@ function copyMissingLegacyOmpOverlayEntries( overlayDir: string, sourceAgentDir: string, pathSegments: string[] = [], - copiedFilePaths: Set = new Set() + copiedFilePaths = new Set() ): boolean { let completed = true let entries: Dirent[] diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts index c6aefa12699..6e7f6f908f0 100644 --- a/src/main/plugins/plugin-dev-watcher.ts +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -98,7 +98,7 @@ export class PluginDevWatcher { private removeSubscription(subscription: WatcherProcessSubscription): void { const index = this.subscriptions.indexOf(subscription) - if (index >= 0) { + if (index !== -1) { this.subscriptions.splice(index, 1) } } diff --git a/src/main/plugins/plugin-host-runtime.ts b/src/main/plugins/plugin-host-runtime.ts index 2a2f5f9fcab..b7f3eb9b8a1 100644 --- a/src/main/plugins/plugin-host-runtime.ts +++ b/src/main/plugins/plugin-host-runtime.ts @@ -20,7 +20,7 @@ export type PluginHostCallError = Error & { code?: string } export type PluginWorkerOrcaApi = { /** Register the handler for a command declared in the manifest. */ commands: { - register(commandId: string, handler: (args: unknown) => unknown | Promise): void + register(commandId: string, handler: (args: unknown) => unknown): void } /** Handle an event the manifest subscribed to (`contributes.events`). */ events: { @@ -55,7 +55,7 @@ export function createPluginWorkerRuntime( const send = options.send const importModule = options.importModule ?? ((specifier: string) => import(specifier)) const exit = options.exit ?? ((code: number) => process.exit(code)) - const commandHandlers = new Map unknown | Promise>() + const commandHandlers = new Map unknown>() const eventHandlers = new Map void | Promise)[]>() const pendingHostCalls = new Map< number, @@ -64,7 +64,7 @@ export function createPluginWorkerRuntime( let nextHostCallId = 0 let initialized = false let shuttingDown = false - let deactivate: (() => unknown | Promise) | null = null + let deactivate: (() => unknown) | null = null async function handleInit(input: { pluginRoot: string @@ -88,7 +88,7 @@ export function createPluginWorkerRuntime( if (module.deactivate !== undefined && typeof module.deactivate !== 'function') { throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) } - deactivate = (module.deactivate as (() => unknown | Promise) | undefined) ?? null + deactivate = (module.deactivate as (() => unknown) | undefined) ?? null const orca: PluginWorkerOrcaApi = { commands: { register(commandId, handler) { diff --git a/src/main/plugins/plugin-worker-output-buffer.ts b/src/main/plugins/plugin-worker-output-buffer.ts index dfbe0478c28..836330c8799 100644 --- a/src/main/plugins/plugin-worker-output-buffer.ts +++ b/src/main/plugins/plugin-worker-output-buffer.ts @@ -34,7 +34,7 @@ export function pipePluginWorkerOutput( while (remaining.length > 0) { if (discarding) { const newline = remaining.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return } discarding = false @@ -42,20 +42,20 @@ export function pipePluginWorkerOutput( continue } const newline = remaining.indexOf('\n') - const segment = newline < 0 ? remaining : remaining.slice(0, newline) + const segment = newline === -1 ? remaining : remaining.slice(0, newline) const available = PLUGIN_WORKER_OUTPUT_LINE_LIMIT - buffered.length if (segment.length > available) { emit(buffered + segment.slice(0, available), true) buffered = '' - discarding = newline < 0 + discarding = newline === -1 } else { buffered += segment - if (newline >= 0) { + if (newline !== -1) { emit(buffered) buffered = '' } } - if (newline < 0) { + if (newline === -1) { return } remaining = remaining.slice(newline + 1) diff --git a/src/main/plugins/plugin-worker-slot-pool.ts b/src/main/plugins/plugin-worker-slot-pool.ts index 5b86acf38d7..7cb6c79d6f1 100644 --- a/src/main/plugins/plugin-worker-slot-pool.ts +++ b/src/main/plugins/plugin-worker-slot-pool.ts @@ -49,7 +49,7 @@ export class PluginWorkerSlotPool { } cancelled = true const index = this.waiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { this.waiters.splice(index, 1) } signal.removeEventListener('abort', waiter.onAbort) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 7e4cdd2e48d..d5e8f8185cd 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -112,7 +112,7 @@ function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] { continue } const eq = line.indexOf('=') - if (eq < 0) { + if (eq === -1) { continue } const key = line.slice(0, eq) diff --git a/src/main/pty/posix-pty-foreground-group.ts b/src/main/pty/posix-pty-foreground-group.ts index 124978c269d..8b3e375dbff 100644 --- a/src/main/pty/posix-pty-foreground-group.ts +++ b/src/main/pty/posix-pty-foreground-group.ts @@ -103,7 +103,7 @@ export function getPosixPtyForegroundGroup( export function signalPosixPtyForegroundGroup( rootPid: number, ptsName: string | undefined, - signal: NodeJS.Signals | string, + signal: NodeJS.Signals | (string & {}), fallback: () => void, deps: PosixPtyForegroundGroupDeps = {} ): void { diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts index 34dcd47130b..ecda8329a2e 100644 --- a/src/main/rate-limits/minimax-request-context.ts +++ b/src/main/rate-limits/minimax-request-context.ts @@ -45,7 +45,7 @@ function parseCookiePairs(cookie: string): { name: string; value: string }[] { .map((part) => { const normalizedPart = part.replace(/^Cookie:\s*/i, '') const eq = normalizedPart.indexOf('=') - if (eq < 0) { + if (eq === -1) { return null } return { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 934d1e72b42..f2ea68b7588 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -47,7 +47,7 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { .map((p) => p.trim()) .map((pair) => { const eq = pair.indexOf('=') - if (eq < 0) { + if (eq === -1) { return null } const name = pair.slice(0, eq).trim() diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 88818b3cb1d..b5f0c7a6c8d 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -162,7 +162,7 @@ export class DeviceRegistry { setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) - if (index < 0 || binding.relayDeviceId !== deviceId) { + if (index === -1 || binding.relayDeviceId !== deviceId) { return false } const nextDevices = this.devices.map((device, candidateIndex) => @@ -175,7 +175,7 @@ export class DeviceRegistry { setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) - if (index < 0 || this.devices[index]?.scope !== 'mobile') { + if (index === -1 || this.devices[index]?.scope !== 'mobile') { return false } // Why: persist before swapping memory so a failed write does not leave a @@ -208,7 +208,7 @@ export class DeviceRegistry { updateLastSeen(deviceId: string): void { const index = this.devices.findIndex((d) => d.deviceId === deviceId) - if (index < 0) { + if (index === -1) { return } // Why: persist before memory swap so a failed write cannot leave a scanned @@ -230,7 +230,7 @@ export class DeviceRegistry { */ updateLastSeenDeferred(deviceId: string): void { const index = this.devices.findIndex((d) => d.deviceId === deviceId) - if (index < 0) { + if (index === -1) { return } if (this.devices[index]!.lastSeenAt === 0) { diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 14c18115636..fc2d761c059 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -66,7 +66,7 @@ function fullRemoteFetchOptions(cwd: string): { cwd: string; timeout: number } { return { cwd, timeout: 60_000 } } -function mockFetchResults(results: (Promise | unknown)[]): void { +function mockFetchResults(results: unknown[]): void { let fetchIndex = 0 gitExecFileAsyncMock.mockImplementation((argv: string[]) => { if (argv[0] === 'rev-parse') { diff --git a/src/main/runtime/linear-save-issue.test.ts b/src/main/runtime/linear-save-issue.test.ts index add8a54d1ab..eb8ed4093d2 100644 --- a/src/main/runtime/linear-save-issue.test.ts +++ b/src/main/runtime/linear-save-issue.test.ts @@ -23,7 +23,7 @@ const issue = { type SaveIssueInternals = { resolveLinearAssignee(input: string, teamId: string, workspaceId: string): Promise - resolveLinearAgentState(input: string, states: unknown[]): unknown | null + resolveLinearAgentState(input: string, states: unknown[]): unknown buildLinearSaveUpdate( params: { labels?: string[] }, current: typeof issue, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b234b24160..4f285c690a1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23664,8 +23664,7 @@ export class OrcaRuntimeService { this.invalidateWorktreeScanCacheForRepo(worktree.repoId) } const shouldClearPushTarget = - Object.prototype.hasOwnProperty.call(metaUpdates, 'pushTarget') && - metaUpdates.pushTarget === null + Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null const normalizedMetaUpdates: Partial = shouldClearPushTarget ? { ...metaUpdates, pushTarget: undefined } : (metaUpdates as Partial) @@ -27788,7 +27787,7 @@ export class OrcaRuntimeService { committedPtyIds, terminalHandlesByPtyId }) - if (failedStopIndex >= 0) { + if (failedStopIndex !== -1) { const failedStop = stopResults[failedStopIndex] throw Object.assign(new Error('terminal_worktree_sleep_failed'), { ptyId: orderedLivePtyIds[failedStopIndex], @@ -27815,7 +27814,7 @@ export class OrcaRuntimeService { committedPtyIds, terminalHandlesByPtyId }) - if (failedStopIndex >= 0 && remainingLivePtyIds.size > 0) { + if (failedStopIndex !== -1 && remainingLivePtyIds.size > 0) { const failedStop = stopResults[failedStopIndex] console.error('[runtime] worktree terminal sleep physical stop failed', { worktreeId: worktree.id, @@ -29834,7 +29833,7 @@ export class OrcaRuntimeService { private syncMobileSessionTabs( snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, unchangedWorktreeIds?: string[], - resyncWorktreeIds: Set = new Set() + resyncWorktreeIds = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -32494,7 +32493,7 @@ export class OrcaRuntimeService { linearSearchForAgents(args: { query: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): ReturnType { return searchLinearIssuesForAgents(args) } @@ -32504,7 +32503,7 @@ export class OrcaRuntimeService { } async linearTeamListForAgents(params: { - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { try { const result = await listLinearTeamsForAgent(params.workspaceId) @@ -32583,7 +32582,7 @@ export class OrcaRuntimeService { async linearProjectListForAgents(params: { query?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const limit = clampLinearSearchLimit(params.limit) try { @@ -32622,7 +32621,7 @@ export class OrcaRuntimeService { filter?: LinearIssueListFilter teamInput?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const filter = params.filter ?? 'assigned' const limit = clampLinearIssueListLimit(params.limit) @@ -34305,7 +34304,7 @@ export class OrcaRuntimeService { private async resolveLinearTeamInput( teamInput: string, - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' ): Promise<{ id: string key: string @@ -36213,12 +36212,12 @@ function applyTerminalLineControls(line: string): { hadControl: boolean } { const carriageIndex = line.lastIndexOf('\r') - const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line + const latestRedraw = carriageIndex !== -1 ? line.slice(carriageIndex + 1) : line if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { return { text: latestRedraw, cursorColumn: latestRedraw.length, - hadControl: carriageIndex >= 0 + hadControl: carriageIndex !== -1 } } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 259664c5544..3fdc2b5011f 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -81,7 +81,7 @@ const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL = function paneKeyMatchSuffix(paneKey: string): string { const colon = paneKey.indexOf(':') - return colon < 0 ? paneKey : paneKey.slice(colon + 1) + return colon === -1 ? paneKey : paneKey.slice(colon + 1) } export type { @@ -285,7 +285,7 @@ type RunListCursor = { // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity. const SCHEMA_VERSION = 26 -function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { +function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { // Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there. return @@ -308,7 +308,7 @@ export class OrchestrationDb { // per-terminal fan-out. Only createDispatchContext flips this false→true. private hasAnyDispatchContextsCache: boolean | undefined - constructor(dbPath: string | ':memory:') { + constructor(dbPath: (string & {}) | ':memory:') { this.db = new Database(dbPath) this.db.pragma('journal_mode = WAL') this.db.pragma('synchronous = NORMAL') diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 23e22008387..15c74d2294e 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -61,7 +61,7 @@ export function createSetupCompletionScanner( } const combined = `${carry}${data}` const markerIndex = combined.lastIndexOf(expectedPrefix) - if (markerIndex >= 0) { + if (markerIndex !== -1) { const suffix = combined.slice(markerIndex + expectedPrefix.length) const match = suffix.match(/^(-?\d+)\r?\n/) if (match) { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 8e09e942a08..b415ddd0e71 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -107,13 +107,13 @@ export type RpcContext = { ) => () => void } -export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown +export type RpcHandler = (params: TParams, ctx: RpcContext) => unknown // Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance. export type RpcMethod = { readonly name: string readonly params: ZodType | null - readonly handler: (params: unknown, ctx: RpcContext) => Promise | unknown + readonly handler: (params: unknown, ctx: RpcContext) => unknown } type DefineMethodSpec = { diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 540333bf5ac..73326756cd9 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -8,7 +8,7 @@ import { } from './client-ui-schemas' // Type-only side effect: keeps the schema/PersistedUIState parity assertions in // the typecheck graph so drift fails the build instead of a paired client. -import type {} from './ui-state-schema-parity-checks' + import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' export const CLIENT_UI_METHODS: RpcMethod[] = [ diff --git a/src/main/runtime/rpc/methods/linear-agent-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-access.test.ts index 945eadb20b8..56c32168460 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.test.ts @@ -355,7 +355,7 @@ type LinearUnconfirmedBuilder = { target: unknown, extra?: unknown ): Error & { data?: { cause?: string; nextSteps?: string[] } } - resolveLinearAgentState(input: string, states: unknown[]): unknown | null + resolveLinearAgentState(input: string, states: unknown[]): unknown linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean linearSavedIssueMatchesIntent(issue: unknown, intent: unknown): boolean notifyLinearLinkedIssueUpdated( @@ -372,13 +372,13 @@ type LinearRetryLookupTester = { parentId: string | null, workspaceId: string, required: boolean - ): Promise + ): Promise getMatchingLinearAttachmentWrite( writeId: string, issueId: string, workspaceId: string, required: boolean - ): Promise + ): Promise getMatchingLinearCreatedIssue( writeId: string, teamId: string, @@ -386,7 +386,7 @@ type LinearRetryLookupTester = { workspaceId: string, required: boolean, intent?: unknown - ): Promise + ): Promise refetchLinearCommentAfterDuplicate( writeId: string, issueId: string, diff --git a/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts index cd51e8a0798..9e0f1568050 100644 --- a/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts @@ -10,7 +10,7 @@ type LinearProjectResolverTester = { id: string name: string }> - readLinearProjectByIdForCreate(id: string, workspaceId: string): Promise + readLinearProjectByIdForCreate(id: string, workspaceId: string): Promise readLinearProjectsForCreate(query: string, workspaceId: string): Promise readLinearProjectsByExactNameForCreate(name: string, workspaceId: string): Promise } diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 5a15b3a8d8f..3b8d42287de 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -138,7 +138,7 @@ function sanitizeToolInput( const result: Record = {} let count = 0 for (const key in value) { - if (!Object.prototype.hasOwnProperty.call(value, key)) { + if (!Object.hasOwn(value, key)) { continue } if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { @@ -148,7 +148,7 @@ function sanitizeToolInput( let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + if (Object.hasOwn(result, boundedKey)) { boundedKey = `${boundedKey}~${count}` } budget.remaining -= boundedKey.length diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index e1eb22f2c52..5737e2c4c8d 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -109,14 +109,14 @@ describe('worktree RPC schemas', () => { const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', displayName: '' }) expect(parsed.displayName).toBe('') - expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(true) + expect(Object.hasOwn(parsed, 'displayName')).toBe(true) }) it('still omits a display name that was never sent', () => { const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', comment: 'note' }) expect(parsed.displayName).toBeUndefined() - expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(false) + expect(Object.hasOwn(parsed, 'displayName')).toBe(false) }) it('ignores a non-string display name rather than persisting it', () => { diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts index 291d35b895f..31d3b5b1952 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts @@ -206,7 +206,7 @@ export class OrchestrationLegacyCompatibility { request: RpcRequest, params: unknown, signal?: AbortSignal - ): Promise { + ): Promise { if (request.method === 'orchestration.send') { return await handleLegacyLifecycleSend({ runtime: this.runtime, diff --git a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts index 9ffe17cca6e..7e76128bd19 100644 --- a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts +++ b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts @@ -17,7 +17,7 @@ export async function handleLegacyLifecycleSend(args: { authority: LegacyCompatibilityAuthority request: RpcRequest params: LegacySendParams -}): Promise { +}): Promise { const { runtime, authority, request, params } = args if (!['heartbeat', 'worker_done', 'escalation'].includes(params.type ?? '')) { return undefined diff --git a/src/main/runtime/rpc/orchestration-legacy-mail.ts b/src/main/runtime/rpc/orchestration-legacy-mail.ts index e41e0c11509..c83d7ffdf2f 100644 --- a/src/main/runtime/rpc/orchestration-legacy-mail.ts +++ b/src/main/runtime/rpc/orchestration-legacy-mail.ts @@ -20,7 +20,7 @@ export async function handleLegacyCheck(args: { request: RpcRequest params: LegacyCheckParams signal?: AbortSignal -}): Promise { +}): Promise { const { runtime, authority, request, params, signal } = args if (params.run) { return undefined @@ -138,7 +138,7 @@ export async function handleLegacyReply(args: { authority: LegacyCompatibilityAuthority request: RpcRequest params: LegacyReplyParams -}): Promise { +}): Promise { const { runtime, authority, request, params } = args const db = runtime.getOrchestrationDb() const original = db.getMessageById(params.id) diff --git a/src/main/runtime/rpc/orchestration-legacy-question.ts b/src/main/runtime/rpc/orchestration-legacy-question.ts index 69dec720a3a..cd463155619 100644 --- a/src/main/runtime/rpc/orchestration-legacy-question.ts +++ b/src/main/runtime/rpc/orchestration-legacy-question.ts @@ -17,7 +17,7 @@ export async function handleLegacyAsk(args: { request: RpcRequest params: LegacyAskParams signal?: AbortSignal -}): Promise { +}): Promise { const { runtime, authority, request, params, signal } = args const dispatch = authority.resolveAskDispatch(request, params) if (!dispatch || dispatch.contract_version !== LEGACY_CONTRACT_VERSION) { diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 2334cd709eb..701970eb58c 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -22,7 +22,7 @@ export class OrchestrationMutationExecutor { async run( request: RpcRequest, params: unknown, - invoke: (mutation?: DurableMutationInvocation) => Promise | unknown, + invoke: (mutation?: DurableMutationInvocation) => unknown, callerFingerprintOverride?: string ): Promise { const requestId = request.orchestrationRequestId diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 4062dacf9a1..a9cbfbc5ab7 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -38,7 +38,7 @@ describe('durable orchestration mutation ledger', () => { } }) - function createHarness(dbPath: string | ':memory:' = ':memory:') { + function createHarness(dbPath: (string & {}) | ':memory:' = ':memory:') { const db = new OrchestrationDb(dbPath) const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2df636da4c0..5ccffe24550 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -6026,7 +6026,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { const wideStartGate = new Promise((resolve) => { releaseWideStart = resolve }) - let wideStopSpy: ReturnType | null = null + let wideStopSpy: ReturnType = null vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => { if (opts.host === '0.0.0.0') { // Why: hold the widen mid-flight (loopback already stopped) so stop() must race the rebind. diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b783382c544..326801c6fa9 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1409,7 +1409,7 @@ export class OrcaRuntimeRpcServer { // never orphan a running 0.0.0.0 listener outside activeTransports (and thus outside stop()). this.activeTransports[index] = widened.transport const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } } try { @@ -1451,7 +1451,7 @@ export class OrcaRuntimeRpcServer { ) this.activeTransports.splice(index, 1) const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports.splice(metaIndex, 1) } this.wsBoundHost = null @@ -1466,7 +1466,7 @@ export class OrcaRuntimeRpcServer { // orphan a live transport outside activeTransports (and thus outside stop()). this.activeTransports[index] = restored.transport const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports[metaIndex] = { kind: 'websocket', endpoint: restored.endpoint } } try { diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts index 9845413d079..6968435a8aa 100644 --- a/src/main/runtime/windows-firewall-remote-scope.ts +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -109,7 +109,7 @@ function maskPrefixLength(maskText: string, version: IpVersion): number | null { function parseIpRange(scope: string): IpRange | null { const trimmed = scope.trim() const dashIndex = trimmed.indexOf('-') - if (dashIndex >= 0) { + if (dashIndex !== -1) { if (dashIndex !== trimmed.lastIndexOf('-')) { return null } @@ -122,7 +122,7 @@ function parseIpRange(scope: string): IpRange | null { } const slashIndex = trimmed.indexOf('/') - if (slashIndex >= 0) { + if (slashIndex !== -1) { if (slashIndex !== trimmed.lastIndexOf('/')) { return null } @@ -195,7 +195,7 @@ function expandEmbeddedIpv4(address: string): string | null { } const lastColon = address.lastIndexOf(':') const ipv4 = parseIpv4(address.slice(lastColon + 1)) - if (lastColon < 0 || !ipv4) { + if (lastColon === -1 || !ipv4) { return null } const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts index 9460c27b6ed..f770511bb27 100644 --- a/src/main/runtime/workspace-session-failed-write-rollback.ts +++ b/src/main/runtime/workspace-session-failed-write-rollback.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util' import type { WorkspaceSessionState } from '../../shared/types' const MISSING = Symbol('missing') -type RollbackValue = unknown | typeof MISSING +type RollbackValue = unknown function isRecord(value: RollbackValue): value is Record { return ( diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 775943052da..a3aef433110 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -66,7 +66,7 @@ export type ForgeReviewByNumberInput = ForgeProviderRepositoryContext & { export type ForgeProvider = { id: ForgeProviderId supportsReviewCreation: boolean - resolveRepository(context: ForgeProviderRepositoryContext): Promise + resolveRepository(context: ForgeProviderRepositoryContext): Promise getReviewForBranch(input: ForgeReviewForBranchInput): Promise getReviewByNumber(input: ForgeReviewByNumberInput): Promise createReview?( diff --git a/src/main/source-control/stacked-hosted-review-creation.ts b/src/main/source-control/stacked-hosted-review-creation.ts index 0a36588f493..53327cbd404 100644 --- a/src/main/source-control/stacked-hosted-review-creation.ts +++ b/src/main/source-control/stacked-hosted-review-creation.ts @@ -38,9 +38,8 @@ export async function createStackedHostedReview( url: created.existingReview.url } } - } else { - currentReview = { number: created.number, url: created.url } } + currentReview = { number: created.number, url: created.url } } if (!currentReview) { return { diff --git a/src/main/ssh/sftp-namespace-resolution.test.ts b/src/main/ssh/sftp-namespace-resolution.test.ts index 98ba2637c7d..5239e834152 100644 --- a/src/main/ssh/sftp-namespace-resolution.test.ts +++ b/src/main/ssh/sftp-namespace-resolution.test.ts @@ -35,10 +35,7 @@ const MARKER_STATS = { size: 0 } -function makeSftp(options: { - startPath?: string | Error | unknown - lstat?: (path: string) => LstatOutcome -}): { +function makeSftp(options: { startPath?: unknown; lstat?: (path: string) => LstatOutcome }): { sftp: SFTPWrapper realpathCalls: string[] lstatCalls: string[] diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 0000f2f353b..d87a7ad8f5b 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -39,7 +39,7 @@ export type SshMultiplexerRequestOptions = { export type NotificationHandler = (method: string, params: Record) => void export type MethodNotificationHandler = (params: Record) => void -export type RequestHandler = (params: Record) => Promise | unknown +export type RequestHandler = (params: Record) => unknown export type MultiplexerDisposeReason = 'shutdown' | 'connection_lost' diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index e002c334aad..90cf99ff9db 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -438,7 +438,7 @@ export class SshRelaySession { async requestAiVaultSessionList( params: SshAiVaultRelayListParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { + ): Promise { if (this.aiVaultListMethodSupported === false) { return null } @@ -465,7 +465,7 @@ export class SshRelaySession { async requestAiVaultSessionTitles( params: SshAiVaultRelayTitleParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { + ): Promise { if (this.aiVaultTitleMethodSupported === false) { return null } diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.ts index b1e0b8d544a..d6d7f1d7a06 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.ts @@ -50,7 +50,7 @@ export function nodeToolchainVersionsMeetRequirements(versionOutput: string): bo function markedVersionMajor(output: string, marker: string): number | null { const lines = output.split(/\r?\n/) const markerIndex = lines.indexOf(marker) - if (markerIndex < 0) { + if (markerIndex === -1) { return null } for (const line of lines.slice(markerIndex + 1)) { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9cf7dc75608..38a16816507 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -71,11 +71,11 @@ function pickShell(): string | null { function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) - if (first < 0) { + if (first === -1) { return [] } const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length) - if (second < 0) { + if (second === -1) { return [] } const value = cleaned.slice(first + DELIMITER.length, second).trim() diff --git a/src/main/warp-themes/discovery.ts b/src/main/warp-themes/discovery.ts index 6f3f80b51da..fd29419c12c 100644 --- a/src/main/warp-themes/discovery.ts +++ b/src/main/warp-themes/discovery.ts @@ -142,7 +142,7 @@ export function getWarpThemeDirectories(): string[] { export function warpThemeSourceLabelForDirectory(directoryPath: string): string { const parts = directoryPath.split(/[\\/]+/).filter(Boolean) const themesIndex = parts.findLastIndex((part) => part.toLowerCase() === 'themes') - if (themesIndex < 0) { + if (themesIndex === -1) { return parts.at(-1) || 'Warp themes' } diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index d34ef495965..0661b6c3d06 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -336,7 +336,7 @@ export function createMainWindow( mainWindow.webContents.setZoomLevel(level) // Why: native traffic lights don't scale with CSS zoom; reposition on startup to stay aligned with the zoomed titlebar. if (process.platform === 'darwin') { - syncTrafficLightPosition(mainWindow, Math.pow(1.2, level)) + syncTrafficLightPosition(mainWindow, 1.2 ** level) } }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 70ce60249b1..a61acccaccb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1461,7 +1461,7 @@ const api = { checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string @@ -4933,8 +4933,7 @@ if (process.contextIsolated) { console.error(error) } } else { - // @ts-ignore (define in dts) window.electron = electronAPI - // @ts-ignore (define in dts) + // @ts-expect-error (define in dts) window.api = api } diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 3166c70a2f5..3cfa6e7f636 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -99,10 +99,10 @@ export class RelayAgentHookServer { private state: HookListenerState = createHookListenerState() // Why: retain envelope metadata so replays match live POSTs. // Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep. - private lastEnvelopeMetaByPaneKey: Map< + private lastEnvelopeMetaByPaneKey = new Map< string, { source: AgentHookSource; env?: string; version?: string } - > = new Map() + >() private assistantMessageRetryTimers = new Map>() private codexSubagentPollTimers = new Map>() private forward: RelayHookForward diff --git a/src/relay/ai-vault-service-client.ts b/src/relay/ai-vault-service-client.ts index 87c0d6e4c84..c873c380506 100644 --- a/src/relay/ai-vault-service-client.ts +++ b/src/relay/ai-vault-service-client.ts @@ -123,7 +123,7 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { continue } const index = this.queue.findIndex((call) => call.lane === lane) - if (index < 0) { + if (index === -1) { continue } const call = this.queue.splice(index, 1)[0]! @@ -241,7 +241,7 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { private cancel(call: RelayAiVaultServiceCall): void { const index = this.queue.indexOf(call) - if (index >= 0) { + if (index !== -1) { this.queue.splice(index, 1) settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) this.pump() diff --git a/src/relay/dispatcher-client-writer.test.ts b/src/relay/dispatcher-client-writer.test.ts index 13e49b59e8e..83b5e7960b7 100644 --- a/src/relay/dispatcher-client-writer.test.ts +++ b/src/relay/dispatcher-client-writer.test.ts @@ -51,7 +51,7 @@ function createWriter( sink.drainWaiters.push(callback) return () => { const index = sink.drainWaiters.indexOf(callback) - if (index >= 0) { + if (index !== -1) { sink.drainWaiters.splice(index, 1) } } diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index baaaa99481e..03bb0b9beba 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -382,7 +382,7 @@ export class ExternalAutomationsHandler { (sessionRun, index) => !usedSessionRunIndexes.has(index) && this.getRunKey(sessionRun) === outputRunKey ) - if (exactMatchIndex >= 0) { + if (exactMatchIndex !== -1) { return exactMatchIndex } @@ -755,7 +755,7 @@ export class ExternalAutomationsHandler { } } - private readHermesSessionDbRunById(jobId: string, runId: string): unknown | null { + private readHermesSessionDbRunById(jobId: string, runId: string): unknown { if (!existsSync(HERMES_STATE_DB)) { return null } diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index ab3bb61faba..25885cfe4dc 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -106,7 +106,7 @@ export function parseUnmergedEntry( */ export function parseBranchDiff( stdout: string, - statsByPath: Map = new Map() + statsByPath = new Map() ): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 9471b2a3b9f..42b8a57856c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -607,11 +607,7 @@ export class PtyHandler { for (const key of envToDelete) { delete result[key] } - if ( - !envToDelete.includes('TERM') && - rendererEnv && - Object.prototype.hasOwnProperty.call(rendererEnv, 'TERM') - ) { + if (!envToDelete.includes('TERM') && rendererEnv && Object.hasOwn(rendererEnv, 'TERM')) { result.TERM = rendererEnv.TERM } // Why: node-pty defaults missing/empty TERM per-platform; normalize so POSIX and Windows children agree. @@ -1431,7 +1427,7 @@ export class PtyHandler { const explicitTerm = !envToDelete.includes('TERM') && env && - Object.prototype.hasOwnProperty.call(env, 'TERM') && + Object.hasOwn(env, 'TERM') && typeof env.TERM === 'string' && env.TERM.length > 0 ? env.TERM diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index acf734acd71..0f0991e5c01 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -61,7 +61,7 @@ function createRecordingSink(highWaterMark?: number): { drainWaiters.push(callback) return () => { const index = drainWaiters.indexOf(callback) - if (index >= 0) { + if (index !== -1) { drainWaiters.splice(index, 1) } } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index cf4b4808668..1c30e2d61e8 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -549,7 +549,7 @@ async function main(): Promise { const marker = process.argv.indexOf('--orca-cli') await runOrcaCliMode( sockPath, - marker >= 0 ? process.argv.slice(marker + 1) : [], + marker !== -1 ? process.argv.slice(marker + 1) : [], endpointCredential ) return diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 84b6bb9e155..643b6497e5d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1476,18 +1476,17 @@ function App(): React.JSX.Element { } else if (settings.theme === 'light') { applyDocumentTheme('light') return undefined - } else { - // system - const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDocumentTheme('system') - const handler = (): void => { - applyDocumentTheme('system') - // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. - scheduleRuntimeGraphSync() - } - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) } + // system + const mq = window.matchMedia('(prefers-color-scheme: dark)') + applyDocumentTheme('system') + const handler = (): void => { + applyDocumentTheme('system') + // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. + scheduleRuntimeGraphSync() + } + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) }, [settings]) useEffect(() => { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index eee4a5a106c..5f73fb31bea 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1240,7 +1240,7 @@ if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { invalidateWorkItemDetailsCacheByMatch(payload) }) } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { import.meta.hot.dispose(() => { workItemMutatedUnsub?.() workItemDetailsCacheEventUnsub?.() diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 69969b5f3bd..aedc7bdd5e0 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1286,7 +1286,7 @@ if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { invalidateWorkItemDetailsCacheByMatch(payload) }) } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { import.meta.hot.dispose(() => { workItemMutatedUnsub?.() workItemDetailsCacheEventUnsub?.() diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 520f2debbdd..29bc0bc4587 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -6960,7 +6960,7 @@ export default function TaskPage(): React.JSX.Element { const authorityPage = pages.findIndex((page) => page?.some((item) => authorityItemKeys.has(taskPageGitHubItemKey(item.repoId, item.id))) ) - const quietPage = authorityPage >= 0 ? authorityPage : currentPage + const quietPage = authorityPage !== -1 ? authorityPage : currentPage const visiblePage = currentPage > quietPage ? currentPage : undefined const pageItemKeys = (page: number): Set => new Set((pages[page] ?? []).map((item) => taskPageGitHubItemKey(item.repoId, item.id))) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index aaccdda3da7..cee7c9bf4ce 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -321,8 +321,8 @@ function shouldIncludeOpenTabInRecentSection({ worktree: Worktree row: RecentWorkspaceTabRow paneSources: TabPaneInputSources - unreadTerminalTabs: Record - unreadAgentCompletionPanes: Record + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record now: number }): boolean { if (worktree.isArchived) { diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 996f474a7c9..c52126ad670 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -355,7 +355,7 @@ export default function AutomationsPage(): React.JSX.Element { const loadAutomationYamlHooksForRepo = useCallback( async (repoId: string): Promise => { const key = getAutomationHooksCacheKey(repoId) - if (Object.prototype.hasOwnProperty.call(automationYamlHooksByRepoKey, key)) { + if (Object.hasOwn(automationYamlHooksByRepoKey, key)) { return automationYamlHooksByRepoKey[key] ?? null } const existingPromise = automationHookCheckPromisesRef.current.get(key) @@ -376,7 +376,7 @@ export default function AutomationsPage(): React.JSX.Element { return hooks } setAutomationYamlHooksByRepoKey((current) => - Object.prototype.hasOwnProperty.call(current, key) ? current : { ...current, [key]: hooks } + Object.hasOwn(current, key) ? current : { ...current, [key]: hooks } ) return hooks }, diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index ada2d7a07ae..d8fd4b91405 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -2883,7 +2883,7 @@ function BrowserPagePane({ return } // Why: convert OS screen cursor coords to renderer CSS pixels — immune to guest/renderer coordinate-space mismatches from zoom/DPI. - const zoomFactor = Math.pow(1.2, window.api.ui.getZoomLevel()) + const zoomFactor = 1.2 ** window.api.ui.getZoomLevel() const x = Math.round((event.screenX - window.screenX) / zoomFactor) const y = Math.round((event.screenY - window.screenY) / zoomFactor) console.debug( diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts index 5351073140b..6ac0fe12c52 100644 --- a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts @@ -127,7 +127,7 @@ export function buildBrowserAddressBarSuggestions({ // Why: the history row gives Enter the same target while showing real page metadata. const duplicateIdx = historySuggestions.findIndex((h) => h.url === topAction.url) - if (duplicateIdx >= 0) { + if (duplicateIdx !== -1) { return historySuggestions.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) } diff --git a/src/renderer/src/components/browser-pane/context-menu-positioning.test.ts b/src/renderer/src/components/browser-pane/context-menu-positioning.test.ts index 397647b181f..647b6696907 100644 --- a/src/renderer/src/components/browser-pane/context-menu-positioning.test.ts +++ b/src/renderer/src/components/browser-pane/context-menu-positioning.test.ts @@ -43,7 +43,7 @@ function computeCorrection( describe('screen-to-viewport coordinate conversion', () => { it('converts screen coords to viewport at zoom 1.0', () => { - const result = computeViewportCoords(650, 400, 100, 50, 1.0) + const result = computeViewportCoords(650, 400, 100, 50, 1) expect(result.x).toBe(550) expect(result.y).toBe(350) }) @@ -64,7 +64,7 @@ describe('screen-to-viewport coordinate conversion', () => { }) it('handles cursor at window origin', () => { - const result = computeViewportCoords(100, 50, 100, 50, 1.0) + const result = computeViewportCoords(100, 50, 100, 50, 1) expect(result.x).toBe(0) expect(result.y).toBe(0) }) @@ -203,7 +203,7 @@ describe('full pipeline', () => { screenCursor.y, windowPos.x, windowPos.y, - 1.0 + 1 ) expect(menuPos.x).toBe(550) @@ -236,7 +236,7 @@ describe('full pipeline', () => { screenCursor.y, windowPos.x, windowPos.y, - 1.0 + 1 ) const cssOffset = { x: 60, y: 40 } const measured = { left: menuPos.x + cssOffset.x, top: menuPos.y + cssOffset.y } @@ -255,7 +255,7 @@ describe('full pipeline', () => { screenCursor.y, windowPos.x, windowPos.y, - 1.0 + 1 ) const corrected = computeCorrection(menuPos.x, menuPos.y, menuPos.x, menuPos.y) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts index 07ffc248c28..155f2bfb5a8 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts @@ -173,7 +173,7 @@ export function getContextualTourStepProgress(args: { stepIndex: number }): { current: number; total: number } | null { const visibleIndex = args.visibleStepIndexes.indexOf(args.stepIndex) - if (visibleIndex < 0) { + if (visibleIndex === -1) { return null } return { current: visibleIndex + 1, total: args.visibleStepIndexes.length } diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index f3d32a9b462..816474eb70f 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -35,7 +35,7 @@ export function patchDashboardSnapshotFromAgentStatus( return { matched: true, snapshot } } const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey) - if (index < 0) { + if (index === -1) { return { matched: false, snapshot } } const card = snapshot.cards[index] diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index 50350fb1b47..6003be19e05 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -27,8 +27,8 @@ function formatCheckTimestamp(value: string | null | undefined): string | null { } type CheckStatusLike = { - status: PRCheckDetail['status'] | string | null | undefined - conclusion: PRCheckDetail['conclusion'] | string | null | undefined + status: PRCheckDetail['status'] | (string & {}) | null | undefined + conclusion: PRCheckDetail['conclusion'] | (string & {}) | null | undefined } function getCheckStatusLabel(check: CheckStatusLike): string { diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index f4bf62d3d54..58898e2d2ed 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -1182,10 +1182,7 @@ export default function CombinedDiffViewer({ if (!detail || detail.worktreeId !== file.worktreeId) { return } - const hasRuntimeOwnerFilter = Object.prototype.hasOwnProperty.call( - detail, - 'runtimeEnvironmentId' - ) + const hasRuntimeOwnerFilter = Object.hasOwn(detail, 'runtimeEnvironmentId') const targetRuntimeOwner = detail.runtimeEnvironmentId?.trim() || null const fileRuntimeOwner = file.runtimeEnvironmentId?.trim() || null if (hasRuntimeOwnerFilter && targetRuntimeOwner !== fileRuntimeOwner) { diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index bbf7ded32ed..4aac74ae495 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -435,7 +435,7 @@ function getCellKey(cell: IpynbCell, index: number): string { } function hasOwnDraft(drafts: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(drafts, key) + return Object.hasOwn(drafts, key) } function EditableTextCell({ diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index c6d01a8cdf8..110f358dd99 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -128,7 +128,7 @@ export function getOpenFilesForExternalFileChange( target: EditorPathMutationTarget ): OpenFile[] { const absolutePath = joinPath(target.worktreePath, target.relativePath) - const hasRuntimeOwnerFilter = Object.prototype.hasOwnProperty.call(target, 'runtimeEnvironmentId') + const hasRuntimeOwnerFilter = Object.hasOwn(target, 'runtimeEnvironmentId') const targetRuntimeOwner = target.runtimeEnvironmentId?.trim() || null return openFiles.filter((file) => { if (file.worktreeId !== target.worktreeId) { diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.ts index 8cc7e07fb27..b455af4e56f 100644 --- a/src/renderer/src/components/editor/editor-panel-draft-selector.ts +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.ts @@ -43,7 +43,7 @@ export function createEditorPanelDraftSelector( const draft = state.editorDrafts[fileId] return ( draft !== previousSelection[fileId] || - (draft === undefined && Object.prototype.hasOwnProperty.call(previousSelection, fileId)) + (draft === undefined && Object.hasOwn(previousSelection, fileId)) ) }) if (!changed) { diff --git a/src/renderer/src/components/editor/monaco-context-menu-paste.ts b/src/renderer/src/components/editor/monaco-context-menu-paste.ts index 4a33fb68352..9b513f8e426 100644 --- a/src/renderer/src/components/editor/monaco-context-menu-paste.ts +++ b/src/renderer/src/components/editor/monaco-context-menu-paste.ts @@ -60,7 +60,7 @@ function resolvePasteMetadata( return { pasteOnNewLine: emptySelectionClipboard && metadata.isFromEmptySelection === true, multicursorText: - typeof metadata.multicursorText !== 'undefined' ? (metadata.multicursorText ?? null) : null, + metadata.multicursorText !== undefined ? (metadata.multicursorText ?? null) : null, mode: metadata.mode ?? null } } diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts index 882b011e9b3..4d33a1f3985 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -9,7 +9,7 @@ import { type MonacoApi = Parameters[1] let provider: IDisposable | null = null -let providerMonaco: MonacoApi | null = null +let providerMonaco: MonacoApi = null const documentsByModel = new Map() export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx index 922e1abe2f1..3cd31be6f6d 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx @@ -1,4 +1,3 @@ -import type {} from '@tiptap/extension-mathematics' import { ChevronRight, ImageIcon, diff --git a/src/renderer/src/components/editor/rich-markdown-terminal-path-paste.ts b/src/renderer/src/components/editor/rich-markdown-terminal-path-paste.ts index cfdb7f422fd..037004a5303 100644 --- a/src/renderer/src/components/editor/rich-markdown-terminal-path-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-terminal-path-paste.ts @@ -14,7 +14,7 @@ function readClipboardText(event: ClipboardEvent, type: string): string { function getWindowsPathBasename(filePath: string): string { const normalized = filePath.replaceAll('/', '\\') const separatorIndex = normalized.lastIndexOf('\\') - return separatorIndex >= 0 ? normalized.slice(separatorIndex + 1) : normalized + return separatorIndex !== -1 ? normalized.slice(separatorIndex + 1) : normalized } function extractClipboardAnchors(html: string): ClipboardAnchor[] { diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 9a8327af090..df174160bb8 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -130,7 +130,7 @@ if (typeof window !== 'undefined') { window.addEventListener('focus', invalidateImageCache) } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { // Why: Vite can re-evaluate this module without a full renderer reload. // Disposing the module-level listener and blob URLs prevents dev-session leaks. import.meta.hot.dispose(disposeImageCacheModuleState) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts index aef4c99dfe6..91e97c2de79 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts @@ -60,7 +60,7 @@ function clampValue(value: number, min: number, max: number): number { } function getWindowStorage(): Storage | null { - return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + return typeof window !== 'undefined' && window.localStorage !== undefined ? window.localStorage : null } diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts index effee92d52a..fb127b23778 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts @@ -48,7 +48,7 @@ function isAnchorY(value: unknown): value is FloatingTerminalAnchorY { } function getWindowStorage(): Storage | null { - return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + return typeof window !== 'undefined' && window.localStorage !== undefined ? window.localStorage : null } diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index b96d3fad358..826d70aea9f 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -43,14 +43,14 @@ function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolea // every picker token prevents one context variant from confirming another. const labelTokens = modelLabel.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? [] const successStart = text.lastIndexOf('setmodelto') - if (successStart < 0 || labelTokens.length === 0) { + if (successStart === -1 || labelTokens.length === 0) { return false } const successText = text.slice(successStart) let tokenEnd = 0 for (const token of labelTokens) { const tokenStart = successText.indexOf(token, tokenEnd) - if (tokenStart < 0) { + if (tokenStart === -1) { return false } tokenEnd = tokenStart + token.length diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts index 3b8ad86a47a..c7e88eaa898 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -52,7 +52,7 @@ function frameCell(line: string): string { if (line.startsWith(FRAME_COLUMN)) { const afterBorder = line.slice(FRAME_COLUMN.length) const columnEnd = afterBorder.indexOf(FRAME_COLUMN) - return (columnEnd < 0 ? afterBorder : afterBorder.slice(0, columnEnd)).trim() + return (columnEnd === -1 ? afterBorder : afterBorder.slice(0, columnEnd)).trim() } // Unframed buffers put the descriptor on the row directly, behind box art. return line.replace(/^[^A-Za-z0-9]+/, '').trim() @@ -171,7 +171,7 @@ function labelNamesReportedModel(reportedModel: string, label: string): boolean let cursor = 1 for (const token of labelTokens.slice(1)) { const found = nameTokens.indexOf(token, cursor) - if (found < 0) { + if (found === -1) { return false } cursor = found + 1 @@ -212,7 +212,7 @@ export function readClaudeSessionOptionsFromTerminalScreen( } const lines = normalizedScreenLines(screen) const headerIndex = lines.findIndex(isClaudeHeaderRow) - if (headerIndex < 0) { + if (headerIndex === -1) { return null } const descriptorCell = claudeModelDescriptorCell(lines, headerIndex) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 7185b6ad1d3..5a4631421bc 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -191,7 +191,7 @@ export function selectPendingIndicesRepresentedByUserTexts( } represented.add(entry.index) const at = remaining.findIndex((candidate) => candidate.index === entry.index) - if (at >= 0) { + if (at !== -1) { remaining.splice(at, 1) } } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 410de8add38..a33b4e27838 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -99,7 +99,7 @@ function messagesAfterPendingBoundary( return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending)) } const boundaryIndex = messages.findIndex((message) => message.id === pending.afterMessageId) - if (boundaryIndex >= 0) { + if (boundaryIndex !== -1) { return messages.slice(boundaryIndex + 1) } // A bounded authoritative read can page the boundary out. Fall back to the @@ -164,7 +164,7 @@ export function prunePendingSends( return false } const openIndex = stillOpen.indexOf(entry) - return openIndex < 0 || !gluedRepresented.has(openIndex) + return openIndex === -1 || !gluedRepresented.has(openIndex) }) return next.length === pending.length ? pending : next } @@ -208,7 +208,7 @@ export function pendingSendsAsMessages( return false } const openIndex = stillVisible.indexOf(entry) - return openIndex < 0 || !gluedRepresented.has(openIndex) + return openIndex === -1 || !gluedRepresented.has(openIndex) }) .map((entry) => ({ id: `pending:${entry.id}`, diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts index ac298ebd6c5..2e3459ac745 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -10,7 +10,7 @@ export type ScoredProjectOption = { function substringHits(text: string, query: string): number[] | null { const at = text.toLowerCase().indexOf(query) - return at < 0 ? null : Array.from({ length: query.length }, (_, offset) => at + offset) + return at === -1 ? null : Array.from({ length: query.length }, (_, offset) => at + offset) } /** @@ -27,7 +27,7 @@ function nameHitsFor(name: string, query: string): number[] | null { let cursor = 0 for (const char of query) { const found = haystack.indexOf(char, cursor) - if (found < 0) { + if (found === -1) { return null } hits.push(found) @@ -60,7 +60,7 @@ export function rankProjectOptions( const scored: ScoredProjectOption[] = [] for (const option of options) { const recentAt = recentIds.indexOf(option.id) - const recency = recentAt < 0 ? 0 : 32 - recentAt * 4 + const recency = recentAt === -1 ? 0 : 32 - recentAt * 4 if (query.length === 0) { scored.push({ option, score: recency, nameHits: [], detailHits: [] }) continue diff --git a/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx b/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx index fc3a60a5504..1f6df54bfb2 100644 --- a/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx +++ b/src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx @@ -50,7 +50,7 @@ function site(id: string, email = `${id}@example.com`): JiraSite { function status( sites: JiraSite[], - selectedSiteId: string | 'all' | null = null + selectedSiteId: (string & {}) | 'all' | null = null ): JiraConnectionStatus { return { connected: true, diff --git a/src/renderer/src/components/new-workspace/use-jira-url-source.ts b/src/renderer/src/components/new-workspace/use-jira-url-source.ts index e1dc6efd6a6..0441e37edc9 100644 --- a/src/renderer/src/components/new-workspace/use-jira-url-source.ts +++ b/src/renderer/src/components/new-workspace/use-jira-url-source.ts @@ -54,7 +54,7 @@ function getPreferredSite( matches: JiraSite[], selectedAccount: AccountSelection | null, requestKey: string, - selectedSiteId: string | 'all' | null | undefined, + selectedSiteId: (string & {}) | 'all' | null | undefined, activeSiteId: string | null | undefined ): JiraSite | null { const explicitId = selectedAccount?.requestKey === requestKey ? selectedAccount.siteId : null diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts index 95c9ccfde97..d0534124397 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts @@ -63,7 +63,7 @@ export function isChecksPanelCreateEligibilityConfirmable(input: { * provisional or draft-preserve path. */ export function shouldOpenChecksPanelCreateComposer(input: { - activeReview: unknown | null + activeReview: unknown isFolder: boolean branch: string hostedReviewCreation: HostedReviewCreationEligibility | null diff --git a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts index 89212dd6149..08bc9fb6d9d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts +++ b/src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.ts @@ -21,7 +21,7 @@ function textGenerationRecipeIsConfigured( actionId: SourceControlTextActionId, recipe: TextGenerationRecipeConfiguration | null | undefined ): boolean { - if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) { + if (Object.hasOwn(recipe ?? {}, 'agentId')) { return true } if ( diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 81dd540ff5f..c8500b99cfa 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -383,12 +383,12 @@ export function AccountsPane({ useState(emptyCodexAccountsState) const [codexAccountsLoaded, setCodexAccountsLoaded] = useState(false) const [codexAction, setCodexAction] = useState< - 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` + 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string}` >('idle') const [claudeAccounts, setClaudeAccounts] = useState(emptyClaudeAccountsState) const [claudeAction, setClaudeAction] = useState< - 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` + 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string}` >('idle') // Why: capture the account's runtime slot when the dialog opens; the roster // can change underneath an open dialog and lose the slot to diff for restarts. diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts index 260d8b48971..f129bea3265 100644 --- a/src/renderer/src/components/settings/SettingsConstants.ts +++ b/src/renderer/src/components/settings/SettingsConstants.ts @@ -11,7 +11,7 @@ export { } from '../../../../shared/ui-zoom-level' export function zoomLevelToPercent(level: number): number { - return Math.round(100 * Math.pow(1.2, level)) + return Math.round(100 * 1.2 ** level) } export function mergeFontSuggestions( diff --git a/src/renderer/src/components/settings/keybinding-override-edits.ts b/src/renderer/src/components/settings/keybinding-override-edits.ts index e0e88d00782..21b339ba98f 100644 --- a/src/renderer/src/components/settings/keybinding-override-edits.ts +++ b/src/renderer/src/components/settings/keybinding-override-edits.ts @@ -15,7 +15,7 @@ export function hasOwnBindingOverride( overrides: KeybindingOverrides, actionId: KeybindingActionId ): boolean { - return Object.prototype.hasOwnProperty.call(overrides, actionId) + return Object.hasOwn(overrides, actionId) } export function removeBindingOverride( diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 1e516bab7a8..370f60f3d2f 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -19,7 +19,7 @@ export function hasOwnActionOverride( overrides: RepoSourceControlAiOverrides['actionOverrides'], actionId: SourceControlActionId ): boolean { - return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) + return Object.hasOwn(overrides ?? {}, actionId) } export function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' { diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index 38c8584c064..040a40eb737 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -15,7 +15,7 @@ export function getProjectedWorktreeLineage( worktree: Worktree, lineageById: Readonly> ): WorktreeLineage | null | undefined { - if (Object.prototype.hasOwnProperty.call(lineageById, worktree.id)) { + if (Object.hasOwn(lineageById, worktree.id)) { return lineageById[worktree.id] } return (worktree as WorktreeWithResolvedLineage).lineage diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 777e9fcfbe6..a154eea8263 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1006,9 +1006,7 @@ export function buildRows( workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), projectOrderBy: ProjectOrderBy = 'manual', lineageById: Record = {}, - worktreeMap: Map = new Map( - worktrees.map((worktree) => [worktree.id, worktree]) - ), + worktreeMap = new Map(worktrees.map((worktree) => [worktree.id, worktree])), nestLineage = false, settings?: AppState['settings'], projectGroups: readonly ProjectGroup[] = [], diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index ceba63d3e07..9224fe2cba3 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -285,7 +285,7 @@ function resolveLeafIdForTitleFallback(args: { } const paneIndex = args.paneTitleEntries.findIndex(([paneId]) => Number(paneId) === args.paneId) - return paneIndex >= 0 ? (leafIds[paneIndex] ?? null) : null + return paneIndex !== -1 ? (leafIds[paneIndex] ?? null) : null } function collectLeafIds(node: TerminalPaneLayoutNode | null): string[] { diff --git a/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx b/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx index 7a99c800eb1..cd853cf42a5 100644 --- a/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx +++ b/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx @@ -23,7 +23,7 @@ function hasVariablePreview( ): boolean { return Boolean( variablePreviews && - Object.prototype.hasOwnProperty.call(variablePreviews, variable) && + Object.hasOwn(variablePreviews, variable) && variablePreviews[variable] !== undefined && variablePreviews[variable] !== null ) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index d02588001b9..baf9c189fde 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -69,7 +69,7 @@ function resolveSnapshotSessionLabel( if (parsed) { const tabs = ctx.tabsByWorktree[worktreeId] ?? [] const tabIndex = tabs.findIndex((t) => t.id === parsed.tabId) - const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined + const tab = tabIndex !== -1 ? tabs[tabIndex] : undefined if (tab) { const custom = tab.customTitle?.trim() if (custom) { @@ -94,7 +94,7 @@ function resolveDaemonSessionLabel( if (tabId && resolvedWorktreeId) { const tabs = ctx.tabsByWorktree[resolvedWorktreeId] ?? [] const tabIndex = tabs.findIndex((t) => t.id === tabId) - const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined + const tab = tabIndex !== -1 ? tabs[tabIndex] : undefined if (tab) { const custom = tab.customTitle?.trim() if (custom) { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index b321dc87f88..67194fbecbf 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -58,8 +58,8 @@ function splitHostCandidate(query: string): { host: string; port: string | null return null } const colonIndex = query.indexOf(':') - const host = colonIndex < 0 ? query : query.slice(0, colonIndex) - const port = colonIndex < 0 ? null : query.slice(colonIndex + 1) + const host = colonIndex === -1 ? query : query.slice(0, colonIndex) + const port = colonIndex === -1 ? null : query.slice(colonIndex + 1) const extension = host.split('.').pop()?.toLowerCase() ?? '' if (HOST_FILE_EXTENSIONS.has(extension)) { return null @@ -94,7 +94,7 @@ function isPrivateIpv4(host: string): boolean { function hasSourceExtensionBeforeColon(query: string): boolean { const colonIndex = query.indexOf(':') - if (colonIndex < 0) { + if (colonIndex === -1) { return false } const extension = query.slice(0, colonIndex).split('.').pop()?.toLowerCase() ?? '' diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 26525128517..abdcc584881 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -223,8 +223,8 @@ export function terminalTabHasUnreadActivity({ unreadAgentCompletionPanes }: { terminalTabId: string - unreadTerminalTabs: Record - unreadAgentCompletionPanes: Record + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record }): boolean { return ( unreadTerminalTabs[terminalTabId] === true || @@ -234,7 +234,7 @@ export function terminalTabHasUnreadActivity({ /** Match pane-level unread completion markers to their owning terminal tab. */ export function hasUnreadAgentCompletionForTerminalTab( - unreadAgentCompletionPanes: Record | undefined, + unreadAgentCompletionPanes: Record | undefined, tabId: string ): boolean { for (const [paneKey, unread] of Object.entries(unreadAgentCompletionPanes ?? {})) { diff --git a/src/renderer/src/components/task-page-github-work-item-authority-refresh.ts b/src/renderer/src/components/task-page-github-work-item-authority-refresh.ts index 6a047760195..3f11b3b93b1 100644 --- a/src/renderer/src/components/task-page-github-work-item-authority-refresh.ts +++ b/src/renderer/src/components/task-page-github-work-item-authority-refresh.ts @@ -50,7 +50,7 @@ export function getTaskPageGitHubRevalidatableAuthorityItemKeys( const quiet = getQuietRevalidateState(queryKey) for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) { const separator = itemKey.indexOf('\0') - if (separator < 0) { + if (separator === -1) { continue } const repoId = itemKey.slice(0, separator) diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts index 970e777ecd3..25616c3c059 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts @@ -53,7 +53,7 @@ export function pendingListOpsForFamily( } const login = pending.listOp.logins[0] const lastIndex = acc.findIndex((op) => op.logins.length === 1 && op.logins[0] === login) - if (lastIndex >= 0) { + if (lastIndex !== -1) { acc[lastIndex] = pending.listOp } else { acc.push(pending.listOp) diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts index 58428e8746e..bfd575ea4ab 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts @@ -65,7 +65,7 @@ export function getTaskPageGitHubConfirmedAuthorityItemKeys(): ReadonlySet= 0 && + separator !== -1 && hasConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1)) ) { keys.add(itemKey) diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts b/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts index 6c33605b569..5895eb28f85 100644 --- a/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts +++ b/src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts @@ -99,7 +99,7 @@ export function settleQuietSearchRevalidate(args: { } const separator = itemKey.indexOf('\0') if ( - separator >= 0 && + separator !== -1 && !hasPendingTaskPageGitHubOpsForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1)) ) { clearConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1)) @@ -111,7 +111,7 @@ export function settleQuietSearchRevalidate(args: { continue } const sep = itemKey.indexOf('\0') - if (sep < 0) { + if (sep === -1) { continue } const repoId = itemKey.slice(0, sep) @@ -134,7 +134,7 @@ export function settleQuietSearchRevalidate(args: { continue } const sep = itemKey.indexOf('\0') - if (sep < 0) { + if (sep === -1) { continue } if ( diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.ts index 3db29f31846..38f1dbe4802 100644 --- a/src/renderer/src/components/task-page-linear-in-orca-issues.ts +++ b/src/renderer/src/components/task-page-linear-in-orca-issues.ts @@ -101,7 +101,7 @@ export function collectLinkedLinearIssueRefsFromWorktrees( (candidate.sourceContext ? getTaskSourceCacheScope(candidate.sourceContext) : '') === sourceScope ) - if ((workspaceId || organizationUrlKey) && unscopedIndex >= 0) { + if ((workspaceId || organizationUrlKey) && unscopedIndex !== -1) { existing[unscopedIndex] = ref } else if (!workspaceId && !organizationUrlKey) { const hasSameSourceScope = existing.some( diff --git a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts index 797407b69a3..5c581ddc652 100644 --- a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts +++ b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts @@ -45,7 +45,7 @@ function pumpInspectionQueue(): void { const priorityIndex = inspectionQueue.findIndex((task) => task.priority === 'pending-title') const next = - priorityIndex >= 0 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift() + priorityIndex !== -1 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift() if (!next) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 1b7c36a0a1f..959b4a9a7e4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -36,9 +36,7 @@ function buildForkWorkspaceName(sourceName: string): string { } function resolveTuiAgent(value: string | null | undefined): TuiAgent | null { - return value && Object.prototype.hasOwnProperty.call(TUI_AGENT_CONFIG, value) - ? (value as TuiAgent) - : null + return value && Object.hasOwn(TUI_AGENT_CONFIG, value) ? (value as TuiAgent) : null } function getUsableForkBase( diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts index 5594bcb3cbe..525a8d2b12a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts @@ -118,7 +118,7 @@ function isPlainAsciiDigitKey(event: XtermBypassEvent): boolean { /** Tracks legacy desktop Linux IME candidate-selection event sequences. */ export function createTerminalImeLinuxCandidateState( now: () => number = () => Date.now(), - pendingPlainLetterKeydownsByCode: Set = new Set() + pendingPlainLetterKeydownsByCode = new Set() ): TerminalImeLinuxCandidateState { let candidateDigitUntil = 0 diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts index d43702057f1..7ca537b014d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -44,7 +44,7 @@ function omitLeafRecord( source: Record | undefined, leafId: string ): Record | undefined { - if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + if (!source || !Object.hasOwn(source, leafId)) { return source } const next = { ...source } diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index bd780e89495..9f21c39056b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -51,7 +51,7 @@ function remapLeafRecord( function collectLeafCounts( node: TerminalPaneLayoutNode, - counts: Map = new Map() + counts = new Map() ): Map { if (node.type === 'leaf') { counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) @@ -73,7 +73,7 @@ function hasLeafPtyBinding( ptyIdsByLeafId: Record | undefined, leafId: string ): boolean { - return ptyIdsByLeafId ? Object.prototype.hasOwnProperty.call(ptyIdsByLeafId, leafId) : false + return ptyIdsByLeafId ? Object.hasOwn(ptyIdsByLeafId, leafId) : false } export function resolveRootlessTerminalLayoutLeafId( diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts index f0742328240..4c44c3e6ead 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts @@ -105,7 +105,7 @@ function coalesceLeafRecord( continue } const retainedLeafId = resolveRetainedLeafId(removedLeafId, retainedLeafIdByRemovedLeafId) - if (!Object.prototype.hasOwnProperty.call(retained, retainedLeafId)) { + if (!Object.hasOwn(retained, retainedLeafId)) { retained[retainedLeafId] = value } } @@ -113,7 +113,7 @@ function coalesceLeafRecord( } function hasLeafRecordValue(source: Record | undefined, leafId: string): boolean { - return Boolean(source && Object.prototype.hasOwnProperty.call(source, leafId)) + return Boolean(source && Object.hasOwn(source, leafId)) } function coalesceScrollbackRecords( @@ -225,7 +225,7 @@ function resolveOwnedActiveLeafId( ptyIdsByLeafId: Record | undefined ): string | null { const hasBinding = (leafId: string): boolean => - Boolean(ptyIdsByLeafId && Object.prototype.hasOwnProperty.call(ptyIdsByLeafId, leafId)) + Boolean(ptyIdsByLeafId && Object.hasOwn(ptyIdsByLeafId, leafId)) if (rootLeafIds.length === 0) { const boundLeafIds = Object.keys(ptyIdsByLeafId ?? {}) if (activeLeafId) { diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-diagnostics.ts b/src/renderer/src/components/terminal-pane/terminal-paste-diagnostics.ts index b32decb98ff..9c89536db50 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-diagnostics.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-diagnostics.ts @@ -29,7 +29,7 @@ export function createRedactedPasteExecutionDiagnostic({ chunksWritten: number durationMs: number plan: TerminalPastePlan - reason?: TerminalPasteExecutionReason | string + reason?: TerminalPasteExecutionReason | (string & {}) status: TerminalPasteExecutionResult['status'] }): string { return [ diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index f9a6e544193..9e9a63a94f2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -168,18 +168,18 @@ export function useTerminalPaneGlobalEffects({ hiddenReasonRef.current = null applyPendingFollowOutputRequests() return - } else { - const hiddenState = hideTerminalVisibility({ - manager, - wasVisible, - wasWorktreeActive, - isWorktreeActive, - hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, - captureViewportPositions - }) - renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended - hiddenReasonRef.current = hiddenState.hiddenReason } + const hiddenState = hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, + captureViewportPositions + }) + renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended + hiddenReasonRef.current = hiddenState.hiddenReason + wasVisibleRef.current = false wasWorktreeActiveRef.current = isWorktreeActive // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 4c686fb28c7..2384e4749b7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -289,7 +289,7 @@ function reportLateWorkspaceCleanupResult( function removeArrayEntry(entries: T[], entry: T): void { const index = entries.indexOf(entry) - if (index >= 0) { + if (index !== -1) { entries.splice(index, 1) } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts index 6239da304b2..280aabd6790 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts @@ -253,7 +253,7 @@ function releaseSettledPostBatchState(state: PostBatchLateSettlementState): void function removeArrayEntry(entries: T[], entry: T): void { const index = entries.indexOf(entry) - if (index >= 0) { + if (index !== -1) { entries.splice(index, 1) } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-skipped-ancestor-reclassification.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-skipped-ancestor-reclassification.ts index e2bfd7a4c3c..cb301b557e2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-skipped-ancestor-reclassification.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-skipped-ancestor-reclassification.ts @@ -65,7 +65,7 @@ export function reclassifySkippedWorkspaceCleanupAncestors({ function removeArrayEntry(entries: T[], entry: T): void { const index = entries.indexOf(entry) - if (index >= 0) { + if (index !== -1) { entries.splice(index, 1) } } diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts index 9924e55d70b..f20ea1e4a30 100644 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts @@ -5,7 +5,7 @@ export type DirectSshReconnectTargetState = { authority: DirectSshAuthority installedAt: number dampUntil: number | null - timer: DirectSshReconnectTimer | null + timer: DirectSshReconnectTimer } export function createDirectSshReconnectTargetState( diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 6920d18ed1e..f5631ffc8ed 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -267,10 +267,7 @@ export function planAgentHibernationCandidates( } if ( runtimeLivenessRequiredWorktreeIds.has(worktreeId) && - !Object.prototype.hasOwnProperty.call( - snapshot.runtimeLivePtyIdsByWorktreeId ?? {}, - worktreeId - ) + !Object.hasOwn(snapshot.runtimeLivePtyIdsByWorktreeId ?? {}, worktreeId) ) { continue } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index e49e3c002fa..11d966cb8bf 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -139,9 +139,7 @@ export function agentTypeToIconAgent(agentType: AgentType | null | undefined): T if (!agentType || agentType === 'unknown') { return null } - return Object.prototype.hasOwnProperty.call(ICONABLE_AGENT_TYPES, agentType) - ? (agentType as TuiAgent) - : null + return Object.hasOwn(ICONABLE_AGENT_TYPES, agentType) ? (agentType as TuiAgent) : null } // Why: shared resolver so all send paths stamp identical agent_kind on agent_prompt_sent telemetry. diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 95c9bfe213c..78d1daa76c6 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -74,7 +74,7 @@ function disposeRendererCrashDiagnostics(): void { rendererSurface = 'main' } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { // Why: Vite can replace this module without a full renderer reload. Remove // global diagnostics hooks so dev sessions do not accumulate listeners. import.meta.hot.dispose(disposeRendererCrashDiagnostics) diff --git a/src/renderer/src/lib/feature-education-telemetry.ts b/src/renderer/src/lib/feature-education-telemetry.ts index 29c07200267..d66fd213722 100644 --- a/src/renderer/src/lib/feature-education-telemetry.ts +++ b/src/renderer/src/lib/feature-education-telemetry.ts @@ -133,7 +133,7 @@ export function trackTerminalPaneSplit(args: { } export function readEmittedSetupGuideStepIds(): Set { - if (typeof globalThis.localStorage === 'undefined') { + if (globalThis.localStorage === undefined) { return new Set() } try { @@ -150,7 +150,7 @@ export function readEmittedSetupGuideStepIds(): Set { } export function persistEmittedSetupGuideStepId(id: FeatureWallSetupStepId): void { - if (typeof globalThis.localStorage === 'undefined') { + if (globalThis.localStorage === undefined) { return } try { @@ -170,7 +170,7 @@ export function reserveTerminalPaneSplitTelemetry( source: TerminalPaneSplitSource, direction: 'vertical' | 'horizontal' ): boolean { - if (typeof globalThis.localStorage === 'undefined') { + if (globalThis.localStorage === undefined) { return true } try { diff --git a/src/renderer/src/lib/markdown-document-templates.ts b/src/renderer/src/lib/markdown-document-templates.ts index 1fadcd2613f..60414d481f0 100644 --- a/src/renderer/src/lib/markdown-document-templates.ts +++ b/src/renderer/src/lib/markdown-document-templates.ts @@ -126,7 +126,7 @@ export function applyMarkdownTemplatePlaceholders( } return content.replace(/\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*\}\}/g, (match, key: string) => { - return Object.prototype.hasOwnProperty.call(replacements, key) ? replacements[key] : match + return Object.hasOwn(replacements, key) ? replacements[key] : match }) } diff --git a/src/renderer/src/lib/native-chat-telemetry.ts b/src/renderer/src/lib/native-chat-telemetry.ts index 0d6f96584d2..1299204b6db 100644 --- a/src/renderer/src/lib/native-chat-telemetry.ts +++ b/src/renderer/src/lib/native-chat-telemetry.ts @@ -15,7 +15,7 @@ import type { TuiAgent } from '../../../shared/types' * the broader `AgentType` string carried by the chat view. Narrowing to the * closed `agent_kind` enum (with an `'other'` fallback) happens here so call * sites never need an unsound `as TuiAgent` cast. */ -export type NativeChatTelemetryAgent = TuiAgent | string | null | undefined +export type NativeChatTelemetryAgent = TuiAgent | (string & {}) | null | undefined // `launchAgent` is optional on terminal tabs (plain shells, manually-started // agents) and the chat view's `AgentType` may carry a string outside the diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index f7165c3aad6..05e71b294dc 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -141,6 +141,7 @@ function performSafeFit(pane: ManagedPane): boolean { if (shouldRestoreScroll) { try { if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) { + // Resume consumed the pending restore; the fallbacks below must not also run. } else if (pinnedScrollState) { const state: ScrollState = pinnedScrollState pinnedScrollState = null diff --git a/src/renderer/src/lib/pane-manager/pane-identity-registry.ts b/src/renderer/src/lib/pane-manager/pane-identity-registry.ts index 0c1951e979f..709ff30ec5f 100644 --- a/src/renderer/src/lib/pane-manager/pane-identity-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-identity-registry.ts @@ -3,9 +3,9 @@ import { mintStablePaneId } from './mint-stable-pane-id' import type { ManagedPaneInternal } from './pane-manager-types' export class PaneIdentityRegistry { - private leafIdByNumericId: Map = new Map() - private numericIdByLeafId: Map = new Map() - private publishedPaneIds: Set = new Set() + private leafIdByNumericId = new Map() + private numericIdByLeafId = new Map() + private publishedPaneIds = new Set() claimLeafId(leafIdHint?: string): TerminalLeafId { if (leafIdHint && isTerminalLeafId(leafIdHint) && !this.numericIdByLeafId.has(leafIdHint)) { diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index fbdbe1f5afe..f6d59594d01 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -66,7 +66,7 @@ export type { export class PaneManager { private root: HTMLElement - private panes: Map = new Map() + private panes = new Map() private activePaneId: number | null = null private nextPaneId = FIRST_PANE_ID private options: PaneManagerOptions diff --git a/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts index 4dff8e4f964..caf3da45092 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts @@ -38,7 +38,7 @@ function createMockElement(className = ''): TestElement { }, replaceChild: (nextChild: TestElement, oldChild: TestElement): TestElement => { const index = element.children.indexOf(oldChild) - if (index >= 0) { + if (index !== -1) { element.children[index] = nextChild } else { element.children.push(nextChild) diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts index 0847f1e3de2..c18e752e883 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -78,7 +78,7 @@ export function resolveRuntimePaneTitleLeafResolution( let hasMultiplePaneTitles = false for (const runtimePaneId in titlesByPaneId) { - if (!Object.prototype.hasOwnProperty.call(titlesByPaneId, runtimePaneId)) { + if (!Object.hasOwn(titlesByPaneId, runtimePaneId)) { continue } diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index f25b806d1ef..8bf56d13980 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -97,7 +97,7 @@ export function filterSetupScriptPromptDismissalsToValidRepos( const unambiguousIdentityByRepoId = new Map() for (const identity of validRepoHostIdentities) { const separatorIndex = identity.indexOf('\0') - const repoId = separatorIndex >= 0 ? identity.slice(separatorIndex + 1) : identity + const repoId = separatorIndex !== -1 ? identity.slice(separatorIndex + 1) : identity unambiguousIdentityByRepoId.set( repoId, unambiguousIdentityByRepoId.has(repoId) ? null : identity diff --git a/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts b/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts index 722f322d80a..907ccee5093 100644 --- a/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts +++ b/src/renderer/src/lib/simulator-pane-shutdown-scheduler.ts @@ -9,7 +9,7 @@ type SimulatorTabReference = { type SimulatorPaneShutdownOptions = { getTabsForWorktree?: (worktreeId: string) => SimulatorTabReference[] - shutdownManagedSimulator?: (worktreeId: string) => Promise | unknown + shutdownManagedSimulator?: (worktreeId: string) => unknown } type ScheduleSimulatorPaneShutdownOptions = SimulatorPaneShutdownOptions & { diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.ts b/src/renderer/src/lib/source-control-launch-agent-selection.ts index e8c00812590..c72098c9481 100644 --- a/src/renderer/src/lib/source-control-launch-agent-selection.ts +++ b/src/renderer/src/lib/source-control-launch-agent-selection.ts @@ -110,7 +110,7 @@ function hasActionOverride( overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined, actionId: SourceControlActionId ): boolean { - return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) + return Object.hasOwn(overrides ?? {}, actionId) } function readRecipeOverrideFields( @@ -120,13 +120,13 @@ function readRecipeOverrideFields( | undefined ): SourceControlActionRecipeOverrideField[] { const fields: SourceControlActionRecipeOverrideField[] = [] - if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) { + if (Object.hasOwn(recipe ?? {}, 'agentId')) { fields.push('agent') } - if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) { + if (Object.hasOwn(recipe ?? {}, 'commandInputTemplate')) { fields.push('commandTemplate') } - if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) { + if (Object.hasOwn(recipe ?? {}, 'agentArgs')) { fields.push('agentArgs') } return fields diff --git a/src/renderer/src/lib/terminal-contrast-correction.test.ts b/src/renderer/src/lib/terminal-contrast-correction.test.ts index 7f5cd76cbfe..2197bdf903c 100644 --- a/src/renderer/src/lib/terminal-contrast-correction.test.ts +++ b/src/renderer/src/lib/terminal-contrast-correction.test.ts @@ -12,7 +12,7 @@ function contrastRatio(a: string, b: string): number { const n = Number.parseInt(hex.replace('#', ''), 16) const toLinear = (channel: number): number => { const c = channel / 255 - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } const r = toLinear((n >> 16) & 0xff) const g = toLinear((n >> 8) & 0xff) diff --git a/src/renderer/src/lib/terminal-layout-equality.ts b/src/renderer/src/lib/terminal-layout-equality.ts index ce11907bed1..716482764e8 100644 --- a/src/renderer/src/lib/terminal-layout-equality.ts +++ b/src/renderer/src/lib/terminal-layout-equality.ts @@ -10,9 +10,7 @@ function sameStringRecord( const rightKeys = Object.keys(right) return ( leftKeys.length === rightKeys.length && - leftKeys.every( - (key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key] - ) + leftKeys.every((key) => Object.hasOwn(right, key) && left[key] === right[key]) ) } diff --git a/src/renderer/src/lib/terminal-quick-command-search.ts b/src/renderer/src/lib/terminal-quick-command-search.ts index 09027cbfd16..8530e915d07 100644 --- a/src/renderer/src/lib/terminal-quick-command-search.ts +++ b/src/renderer/src/lib/terminal-quick-command-search.ts @@ -93,11 +93,11 @@ function scoreCandidate(query: string, rawCandidate: string, baseScore: number): return baseScore + 50 } const wordIndex = candidate.indexOf(` ${query}`) - if (wordIndex >= 0) { + if (wordIndex !== -1) { return baseScore + 100 + wordIndex } const index = candidate.indexOf(query) - if (index >= 0) { + if (index !== -1) { return baseScore + 200 + index } return NO_MATCH diff --git a/src/renderer/src/lib/terminal-themes/shared.ts b/src/renderer/src/lib/terminal-themes/shared.ts index 023213a6605..3d3ee17abba 100644 --- a/src/renderer/src/lib/terminal-themes/shared.ts +++ b/src/renderer/src/lib/terminal-themes/shared.ts @@ -7,7 +7,7 @@ export function mergeTerminalThemeCatalogs( for (const catalog of catalogs) { for (const [name, theme] of Object.entries(catalog)) { - if (Object.prototype.hasOwnProperty.call(merged, name)) { + if (Object.hasOwn(merged, name)) { throw new Error(`Duplicate terminal theme name: ${name}`) } merged[name] = theme diff --git a/src/renderer/src/lib/terminal-title-contrast.ts b/src/renderer/src/lib/terminal-title-contrast.ts index a54a4819092..38afe002ed2 100644 --- a/src/renderer/src/lib/terminal-title-contrast.ts +++ b/src/renderer/src/lib/terminal-title-contrast.ts @@ -183,7 +183,7 @@ function compositeRgb(foreground: RgbaColor, background: RgbaColor, alpha: numbe function relativeLuminance(rgb: Pick): number { const toLinear = (channel: number): number => { const normalized = channel / 255 - return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4) + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 } return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b) } diff --git a/src/renderer/src/lib/ui-zoom.ts b/src/renderer/src/lib/ui-zoom.ts index 7ab1a00e19f..eb1749f222c 100644 --- a/src/renderer/src/lib/ui-zoom.ts +++ b/src/renderer/src/lib/ui-zoom.ts @@ -6,7 +6,7 @@ const isMac = navigator.userAgent.includes('Mac') * and repositions the native macOS traffic lights to stay aligned. */ export function applyUIZoom(level: number): void { - const zoomFactor = Math.pow(1.2, level) + const zoomFactor = 1.2 ** level window.api.ui.setZoomLevel(level) document.documentElement.style.setProperty('--ui-zoom-factor', String(zoomFactor)) if (isMac) { @@ -20,7 +20,7 @@ export function applyUIZoom(level: number): void { */ export function syncZoomCSSVar(): void { const level = window.api.ui.getZoomLevel() - const zoomFactor = Math.pow(1.2, level) + const zoomFactor = 1.2 ** level document.documentElement.style.setProperty('--ui-zoom-factor', String(zoomFactor)) if (isMac) { window.api.ui.syncTrafficLights(zoomFactor) diff --git a/src/renderer/src/lib/window-visibility-interval.ts b/src/renderer/src/lib/window-visibility-interval.ts index bdd250d5c16..aa1337fc095 100644 --- a/src/renderer/src/lib/window-visibility-interval.ts +++ b/src/renderer/src/lib/window-visibility-interval.ts @@ -3,7 +3,7 @@ export type WindowVisibilityIntervalTimer = ReturnType export function isWindowVisible(): boolean { return ( typeof document === 'undefined' || - typeof document.visibilityState === 'undefined' || + document.visibilityState === undefined || document.visibilityState === 'visible' ) } diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index 23feeb735e0..29e06dc74c9 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -25,7 +25,7 @@ const MATCH_TIER = { exact: 0, prefix: 1, wordStart: 2, substring: 3 } as const function matchTier(shortcode: string, query: string): number | null { const index = shortcode.indexOf(query) - if (index < 0) { + if (index === -1) { return null } if (index === 0) { 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 526833a5969..cad1147ed1e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -3012,7 +3012,7 @@ describe('applyWebSessionTabsSnapshot', () => { NOW ) as Partial - expect(Object.prototype.hasOwnProperty.call(patch, 'openFiles')).toBe(false) + expect(Object.hasOwn(patch, 'openFiles')).toBe(false) expect(state.openFiles).toEqual([unchanged]) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 2e28113c2d8..107c085db4e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3356,8 +3356,7 @@ export function applyWebSessionTabsStorePatch( let mirroredAgentStatusChanged = false useAppStore.setState((state) => { const patch = buildPatch(state) - mirroredAgentStatusChanged = - patch !== state && Object.prototype.hasOwnProperty.call(patch, 'agentStatusByPaneKey') + mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') return patch }) // Why: paired-web snapshots bypass setAgentStatus, so arm the stale-boundary timer explicitly like local hook events do. diff --git a/src/renderer/src/store/running-terminal-close-confirm.ts b/src/renderer/src/store/running-terminal-close-confirm.ts index 618d30c98c5..88db03a5d38 100644 --- a/src/renderer/src/store/running-terminal-close-confirm.ts +++ b/src/renderer/src/store/running-terminal-close-confirm.ts @@ -83,7 +83,7 @@ export const useRunningTerminalCloseConfirmStore = create queued.terminalTabId === request.terminalTabId ) - if (queuedIndex >= 0) { + if (queuedIndex !== -1) { queuedRequests[queuedIndex] = mergeRequests(queuedRequests[queuedIndex]!, request) return } diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 78a7a31b358..3911830c072 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -938,7 +938,7 @@ export const createBrowserSlice: StateCreator = if (activePageId) { const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) - const targetPage = activePageIndex >= 0 ? restoredPages[activePageIndex] : null + const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null if (targetPage && targetPage.id !== restoredPages[0]?.id) { get().setActiveBrowserPage(restored.id, targetPage.id) } diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index 1154fd649b5..99b11cb26bb 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -117,7 +117,7 @@ function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState } // Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. -const persistQueueByWorktree: Map> = new Map() +const persistQueueByWorktree = new Map>() // Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure. // Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write so callers can roll back. diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ba0685b54f8..4b2efe9516a 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -4163,7 +4163,7 @@ export const createEditorSlice: StateCreator = (s // Why: session-local conflict tracking (Resolved-locally) lives only in the renderer; main returns raw git status, so the renderer owns conflictStatusSource. setGitStatus: (worktreeId, status) => set((s) => { - const hadStatusEntry = Object.prototype.hasOwnProperty.call(s.gitStatusByWorktree, worktreeId) + const hadStatusEntry = Object.hasOwn(s.gitStatusByWorktree, worktreeId) const prevEntries = s.gitStatusByWorktree[worktreeId] ?? [] const prevOperation = s.gitConflictOperationByWorktree[worktreeId] ?? 'unknown' const currentTracked = { ...s.trackedConflictPathsByWorktree[worktreeId] } diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts index be1c2255621..f21da2b153a 100644 --- a/src/renderer/src/store/slices/repo-host-identity.ts +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -29,7 +29,7 @@ export function findRepoForHost( repos: readonly T[], repoId: string, options: { - hostId?: ExecutionHostId | string | null + hostId?: ExecutionHostId | (string & {}) | null settings?: Pick | null } = {} ): T | null { diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 9212313f0c7..f382ba21e2a 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -41,9 +41,7 @@ function areValuesEqual(a: unknown, b: unknown): boolean { return false } return keys.every( - (key) => - Object.prototype.hasOwnProperty.call(bRecord, key) && - areValuesEqual(aRecord[key], bRecord[key]) + (key) => Object.hasOwn(bRecord, key) && areValuesEqual(aRecord[key], bRecord[key]) ) } @@ -56,7 +54,7 @@ function areReposEqual(a: Repo, b: Repo): boolean { return false } for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(b, key)) { + if (!Object.hasOwn(b, key)) { return false } if (!areValuesEqual(a[key], b[key])) { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 39b9edd20e1..04f395cc13a 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -888,9 +888,7 @@ function areCatalogEntriesEqual(a: unknown, b: unknown): boolean { if (keys.length !== Object.keys(b).length) { return false } - return keys.every( - (key) => Object.prototype.hasOwnProperty.call(b, key) && areCatalogEntriesEqual(a[key], b[key]) - ) + return keys.every((key) => Object.hasOwn(b, key) && areCatalogEntriesEqual(a[key], b[key])) } // Why: returning `base` unchanged keeps referential-equality selectors quiet, so a diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index ba2f5211efc..43707fa86d6 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -200,10 +200,7 @@ export function buildRemovedSshTargetCleanupPatch( const nextTransientClearedConnections = { ...state.transientClearedAgentStatusConnectionIds } - const removedTransientClearBlock = Object.prototype.hasOwnProperty.call( - nextTransientClearedConnections, - targetId - ) + const removedTransientClearBlock = Object.hasOwn(nextTransientClearedConnections, targetId) delete nextTransientClearedConnections[targetId] const nextConnectionStates = new Map(state.sshConnectionStates) const removedConnectionState = nextConnectionStates.delete(targetId) @@ -211,18 +208,9 @@ export function buildRemovedSshTargetCleanupPatch( const removedLabel = nextLabels.delete(targetId) const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds) const removedHydrated = nextHydrated.delete(targetId) - const removedSyncStatus = Object.prototype.hasOwnProperty.call( - state.remoteWorkspaceSyncStatusByTargetId, - targetId - ) - const removedPortForwards = Object.prototype.hasOwnProperty.call( - state.portForwardsByConnection, - targetId - ) - const removedDetectedPorts = Object.prototype.hasOwnProperty.call( - state.detectedPortsByConnection, - targetId - ) + const removedSyncStatus = Object.hasOwn(state.remoteWorkspaceSyncStatusByTargetId, targetId) + const removedPortForwards = Object.hasOwn(state.portForwardsByConnection, targetId) + const removedDetectedPorts = Object.hasOwn(state.detectedPortsByConnection, targetId) const nextSyncStatus = { ...state.remoteWorkspaceSyncStatusByTargetId } delete nextSyncStatus[targetId] const nextPortForwards = { ...state.portForwardsByConnection } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index ea3ad4bb92f..9675be9a693 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -393,9 +393,7 @@ export function withoutErasedRequiredWorktreeFields( updates: Partial ): Partial { const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter( - (key) => - updates[key as keyof WorktreeMeta] === undefined && - Object.prototype.hasOwnProperty.call(updates, key) + (key) => updates[key as keyof WorktreeMeta] === undefined && Object.hasOwn(updates, key) ) if (erased.length === 0) { return updates diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index 5a72d3d0530..1582e649017 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -43,7 +43,7 @@ export type WorktreeNavHistoryTaskDetailEntry = export type WorktreeNavHistoryViewEntry = | WorktreeNavHistorySimpleViewEntry | WorktreeNavHistoryTaskDetailEntry -export type WorktreeNavHistoryEntry = string | WorktreeNavHistoryViewEntry +export type WorktreeNavHistoryEntry = (string & {}) | WorktreeNavHistoryViewEntry export type WorktreeNavHistorySlice = { // Linear history, oldest -> newest. diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 706fa02de2f..693eaedacb0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1394,8 +1394,7 @@ async function listWorktreeLineageForRuntime( workspaceLineage?: Record } const normalizeLineageResponse = (value: Record | LineageListResponse) => - Object.prototype.hasOwnProperty.call(value, 'lineage') || - Object.prototype.hasOwnProperty.call(value, 'workspaceLineage') + Object.hasOwn(value, 'lineage') || Object.hasOwn(value, 'workspaceLineage') ? { worktreeLineageById: (value as LineageListResponse).lineage ?? {}, workspaceLineageByChildKey: (value as LineageListResponse).workspaceLineage ?? {} @@ -2144,15 +2143,11 @@ function getHostedReviewLinkForMetaRefresh( existingWorktree: Worktree | undefined, key: HostedReviewLinkKey ): number | null { - return Object.prototype.hasOwnProperty.call(updates, key) - ? (updates[key] ?? null) - : (existingWorktree?.[key] ?? null) + return Object.hasOwn(updates, key) ? (updates[key] ?? null) : (existingWorktree?.[key] ?? null) } function hasExplicitPushTargetClear(updates: Partial): boolean { - return ( - Object.prototype.hasOwnProperty.call(updates, 'pushTarget') && updates.pushTarget === undefined - ) + return Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined } type RuntimeWorktreeMetaUpdates = Omit, 'pushTarget'> & { @@ -5557,7 +5552,7 @@ export const createWorktreeSlice: StateCreator set((s) => { for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((tab) => tab.id === tabId) - if (index < 0) { + if (index === -1) { continue } const tab = tabs[index] diff --git a/src/renderer/src/web/web-clipboard-copy-fallback.test.ts b/src/renderer/src/web/web-clipboard-copy-fallback.test.ts index e6242c11ae4..28e9f1dc4dd 100644 --- a/src/renderer/src/web/web-clipboard-copy-fallback.test.ts +++ b/src/renderer/src/web/web-clipboard-copy-fallback.test.ts @@ -44,7 +44,7 @@ function createFakeDocument(options?: FakeDocOptions) { removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { if (type === 'copy') { const index = listeners.indexOf(listener) - if (index >= 0) { + if (index !== -1) { listeners.splice(index, 1) } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a1e89aa7135..2baa23a7b28 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1081,7 +1081,7 @@ function removeConflictingWebOverrides( const conflictingOverrides = new Set() for (const conflict of conflicts) { for (const actionId of conflict.actionIds) { - if (Object.prototype.hasOwnProperty.call(next, actionId)) { + if (Object.hasOwn(next, actionId)) { conflictingOverrides.add(actionId) } } @@ -1870,8 +1870,7 @@ function createWorktreesApi(): NonNullable['worktrees']> { }), updateMeta: async ({ worktreeId, updates }) => { const rpcUpdates = - Object.prototype.hasOwnProperty.call(updates, 'pushTarget') && - updates.pushTarget === undefined + Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined ? { ...updates, pushTarget: null } : updates const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 44032c03b24..292c2120a44 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -842,7 +842,7 @@ function readString(record: Record, key: string): string | unde } function hasOwnField(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key) + return Object.hasOwn(record, key) } function hasAnyOwnField(record: Record, keys: readonly string[]): boolean { diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts index e41ba3ac028..d8a93499287 100644 --- a/src/shared/automation-schedules.ts +++ b/src/shared/automation-schedules.ts @@ -301,7 +301,7 @@ export function parseAutomationRrule(rrule: string): { } const dayCode = rule.byDay[0] const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]) - if (dayOfWeek < 0) { + if (dayOfWeek === -1) { throw new Error('Invalid recurrence day.') } return { diff --git a/src/shared/browser-page-zoom.ts b/src/shared/browser-page-zoom.ts index 28997ebab4a..83a23d90752 100644 --- a/src/shared/browser-page-zoom.ts +++ b/src/shared/browser-page-zoom.ts @@ -23,7 +23,7 @@ export function normalizeBrowserPageZoomLevel(value: unknown): number { export function browserPageZoomLevelToPercent(level: number): number { // Why: Electron zoom levels are exponential; show the same percentage users // expect from Chromium browser zoom controls. - return Math.round(100 * Math.pow(1.2, normalizeBrowserPageZoomLevel(level))) + return Math.round(100 * 1.2 ** normalizeBrowserPageZoomLevel(level)) } export function nextBrowserPageZoomLevel( diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index be5d24a524c..3907d133a30 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -30,7 +30,7 @@ export function getExternalWorktreeParentPath(worktreePath: string | undefined): return `//${parts.slice(0, -1).join('/')}` } const lastSeparatorIndex = normalized.lastIndexOf('/') - if (lastSeparatorIndex < 0) { + if (lastSeparatorIndex === -1) { return UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH } if (lastSeparatorIndex === 0) { diff --git a/src/shared/gitlab-types.ts b/src/shared/gitlab-types.ts index 703085fb8a2..c9f2a0fffba 100644 --- a/src/shared/gitlab-types.ts +++ b/src/shared/gitlab-types.ts @@ -215,7 +215,7 @@ export type GitLabTodo = { id: number /** Free-form GitLab action name, e.g. 'assigned', 'mentioned', 'build_failed', 'review_requested'. */ actionName: string - targetType: GitLabTodoTargetType | string + targetType: GitLabTodoTargetType | (string & {}) /** iid for MR/Issue targets; absent for Commit/Note targets where the identifier is a SHA or note ID. */ targetIid: number | null targetTitle: string diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index ad8e84ecbad..6adaa966ead 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -19,7 +19,7 @@ export type JiraViewer = { avatarUrl?: string } -export type JiraSiteSelection = string | 'all' +export type JiraSiteSelection = (string & {}) | 'all' export type JiraConnectionStatus = { connected: boolean diff --git a/src/shared/linear-agent-access.ts b/src/shared/linear-agent-access.ts index 2ffa17cb406..69169268767 100644 --- a/src/shared/linear-agent-access.ts +++ b/src/shared/linear-agent-access.ts @@ -115,13 +115,13 @@ export type LinearIssueListRequest = { filter?: LinearIssueListFilter teamInput?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' } export type LinearProjectListRequest = { query?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' } export type LinearStatusSetRequest = LinearWriteTargetRequest & { diff --git a/src/shared/linear-agent-result-types.ts b/src/shared/linear-agent-result-types.ts index e986460007b..ae04c842d4d 100644 --- a/src/shared/linear-agent-result-types.ts +++ b/src/shared/linear-agent-result-types.ts @@ -144,7 +144,7 @@ export type LinearSearchResult = { issues: LinearSearchIssueSummary[] meta: { query: string - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' limit: number returned: number limitReached: boolean @@ -178,7 +178,7 @@ export type LinearTeamSummary = { export type LinearTeamListResult = { teams: LinearTeamSummary[] meta: { - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' returned: number partial: boolean workspaceErrors: { @@ -211,7 +211,7 @@ export type LinearIssueListResult = { issues: LinearSearchIssueSummary[] meta: { filter: LinearIssueListFilter - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' team?: LinearTeamSummary limit: number returned: number @@ -242,7 +242,7 @@ export type LinearProjectListResult = { projects: LinearAgentProjectSummary[] meta: { query?: string - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' limit: number returned: number hasMore: boolean diff --git a/src/shared/linear-issue-view-resume-state.ts b/src/shared/linear-issue-view-resume-state.ts index 11fbb967570..82c03f87c2e 100644 --- a/src/shared/linear-issue-view-resume-state.ts +++ b/src/shared/linear-issue-view-resume-state.ts @@ -173,9 +173,7 @@ export function selectLinearWorkspaceIssueFilter( if (!workspaceId) { return emptyLinearIssueAttributeFilter() } - const filter = Object.prototype.hasOwnProperty.call(filters, workspaceId) - ? filters[workspaceId] - : undefined + const filter = Object.hasOwn(filters, workspaceId) ? filters[workspaceId] : undefined return filter ? canonicalizeLinearIssueAttributeFilter(filter) : emptyLinearIssueAttributeFilter() } diff --git a/src/shared/linear-links.ts b/src/shared/linear-links.ts index 0f1db592736..097a0f5e1f9 100644 --- a/src/shared/linear-links.ts +++ b/src/shared/linear-links.ts @@ -88,7 +88,7 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n const parts = parsed.pathname.split('/').filter(Boolean) const issueIndex = parts.indexOf('issue') const organizationUrlKey = parts[0] - const rawIdentifier = issueIndex >= 0 ? parts[issueIndex + 1] : undefined + const rawIdentifier = issueIndex !== -1 ? parts[issueIndex + 1] : undefined if (!organizationUrlKey || !rawIdentifier) { return null } diff --git a/src/shared/linear-mcp-issue-list.ts b/src/shared/linear-mcp-issue-list.ts index c668c848aef..6955eef16ab 100644 --- a/src/shared/linear-mcp-issue-list.ts +++ b/src/shared/linear-mcp-issue-list.ts @@ -19,7 +19,7 @@ export type LinearMcpIssueListRequest = { createdAt?: string updatedAt?: string includeArchived?: boolean - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' } export type LinearMcpIssueListResult = { @@ -30,7 +30,7 @@ export type LinearMcpIssueListResult = { hasMore: boolean nextCursor?: string orderBy: 'createdAt' | 'updatedAt' - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' partial: boolean workspaceErrors: { workspace: LinearWorkspaceCandidate diff --git a/src/shared/mcp-config.ts b/src/shared/mcp-config.ts index 0cc0ce60d3b..09fbc6d3865 100644 --- a/src/shared/mcp-config.ts +++ b/src/shared/mcp-config.ts @@ -165,7 +165,7 @@ export function inspectMcpConfigContent( function collectMcpServerEntries(rawServers: Record): [string, unknown][] | null { const entries: [string, unknown][] = [] for (const name in rawServers) { - if (!Object.prototype.hasOwnProperty.call(rawServers, name)) { + if (!Object.hasOwn(rawServers, name)) { continue } if ( diff --git a/src/shared/mcp-server-inspection.ts b/src/shared/mcp-server-inspection.ts index ab7a79cf40b..a475ff413e8 100644 --- a/src/shared/mcp-server-inspection.ts +++ b/src/shared/mcp-server-inspection.ts @@ -66,7 +66,7 @@ function inspectMcpEnv(env: unknown): BoundedEnv { const masked: Record = {} let fields = 0 for (const key in env) { - if (!Object.prototype.hasOwnProperty.call(env, key)) { + if (!Object.hasOwn(env, key)) { continue } fields += 1 diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index a55f0c9c272..34c2e9ae32d 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -21,7 +21,7 @@ function stripImagePromptMarkersFromFirstText( blocks: readonly NativeChatBlock[] ): NativeChatBlock[] { const textIndex = blocks.findIndex(isTextBlock) - if (textIndex < 0) { + if (textIndex === -1) { return blocks as NativeChatBlock[] } const block = blocks[textIndex] diff --git a/src/shared/native-chat-session-option-commands.ts b/src/shared/native-chat-session-option-commands.ts index 6fb5095be8d..e4efd5efdf9 100644 --- a/src/shared/native-chat-session-option-commands.ts +++ b/src/shared/native-chat-session-option-commands.ts @@ -22,7 +22,7 @@ export function parseBuiltSessionOptionCommand( const marker = '__orca_session_option_value__' const template = build(marker) const markerIndex = template.indexOf(marker) - if (markerIndex < 0) { + if (markerIndex === -1) { return null } const prefix = template.slice(0, markerIndex) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 3f71d997e15..a048e16da0e 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -283,7 +283,7 @@ function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet = {} let count = 0 for (const key in value) { - if (!Object.prototype.hasOwnProperty.call(value, key)) { + if (!Object.hasOwn(value, key)) { continue } if (count >= MAX_PREVIEW_COLLECTION_ITEMS) { diff --git a/src/shared/persisted-ui-equality.ts b/src/shared/persisted-ui-equality.ts index b82320737c9..701359c487d 100644 --- a/src/shared/persisted-ui-equality.ts +++ b/src/shared/persisted-ui-equality.ts @@ -36,7 +36,7 @@ export function persistedUIValuesEqual(left: unknown, right: unknown): boolean { for (const key of leftKeys) { if ( - !Object.prototype.hasOwnProperty.call(rightRecord, key) || + !Object.hasOwn(rightRecord, key) || !persistedUIValuesEqual(leftRecord[key], rightRecord[key]) ) { return false diff --git a/src/shared/remote-runtime-shared-control-ready.ts b/src/shared/remote-runtime-shared-control-ready.ts index 23461245d0d..9d0a92c500c 100644 --- a/src/shared/remote-runtime-shared-control-ready.ts +++ b/src/shared/remote-runtime-shared-control-ready.ts @@ -37,7 +37,7 @@ export function waitForSharedControlReadyWithTimeout(args: { } settled = true const index = args.readyWaiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { args.readyWaiters.splice(index, 1) } reject(remoteRuntimeUnavailableError()) @@ -65,7 +65,7 @@ export function waitForSharedControlReadyWithTimeout(args: { args.open() } catch (error) { const index = args.readyWaiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { args.readyWaiters.splice(index, 1) } waiter.reject(error instanceof Error ? error : remoteRuntimeUnavailableError(String(error))) diff --git a/src/shared/setup-script-import-codex-environment.ts b/src/shared/setup-script-import-codex-environment.ts index 77a8e48296d..d684d197497 100644 --- a/src/shared/setup-script-import-codex-environment.ts +++ b/src/shared/setup-script-import-codex-environment.ts @@ -136,7 +136,7 @@ function parseTomlMultilineString( remainder = lines[index] } const closeIndex = remainder.indexOf(delimiter) - if (closeIndex >= 0) { + if (closeIndex !== -1) { if (!oversized && !append(remainder.slice(0, closeIndex))) { oversized = true } diff --git a/src/shared/setup-script-imports.ts b/src/shared/setup-script-imports.ts index 5aa698d13d1..5c37b99dab0 100644 --- a/src/shared/setup-script-imports.ts +++ b/src/shared/setup-script-imports.ts @@ -186,7 +186,7 @@ function resolveSupersetScriptValue( } for (const field in localRecord) { - if (!Object.prototype.hasOwnProperty.call(localRecord, field)) { + if (!Object.hasOwn(localRecord, field)) { continue } if (field !== 'before' && field !== 'after') { @@ -283,7 +283,7 @@ function collectUnsupportedCmuxCommandFields( const supportedFields = new Set(['name', 'title', 'description', 'keywords', 'command']) const unsupportedFields: string[] = [] for (const field in command) { - if (!Object.prototype.hasOwnProperty.call(command, field)) { + if (!Object.hasOwn(command, field)) { continue } if (!supportedFields.has(field)) { diff --git a/src/shared/source-control-ai-actions.ts b/src/shared/source-control-ai-actions.ts index 8b1a8acb636..19dc70aa652 100644 --- a/src/shared/source-control-ai-actions.ts +++ b/src/shared/source-control-ai-actions.ts @@ -195,7 +195,7 @@ export function renderSourceControlActionCommandTemplate( // Why: placeholder names may start with letters or underscores. // Why: only own keys are real variables; inherited Object.prototype names // (e.g. `constructor`) must stay visible instead of rendering their value. - if (!Object.prototype.hasOwnProperty.call(variables, name)) { + if (!Object.hasOwn(variables, name)) { return match } const value = variables[name] diff --git a/src/shared/source-control-ai.ts b/src/shared/source-control-ai.ts index 2ca3541916a..7445ee5b874 100644 --- a/src/shared/source-control-ai.ts +++ b/src/shared/source-control-ai.ts @@ -194,7 +194,7 @@ function normalizeOperationRecord( } const normalized: Partial> = {} for (const operation of SOURCE_CONTROL_AI_OPERATIONS) { - if (!Object.prototype.hasOwnProperty.call(value, operation)) { + if (!Object.hasOwn(value, operation)) { continue } const normalizedValue = normalizeValue(value[operation]) @@ -214,7 +214,7 @@ function normalizeActionRecord( } const normalized: Partial> = {} for (const actionId of SOURCE_CONTROL_ACTION_IDS) { - if (!Object.prototype.hasOwnProperty.call(value, actionId)) { + if (!Object.hasOwn(value, actionId)) { continue } const normalizedValue = normalizeValue(value[actionId]) @@ -388,7 +388,7 @@ function legacyPromptFromCommandTemplate( function hasActionAgentRecipe(recipe: { agentId?: TuiAgent | CustomAgentId | null }): recipe is { agentId: TuiAgent | CustomAgentId | null } { - return Object.prototype.hasOwnProperty.call(recipe, 'agentId') + return Object.hasOwn(recipe, 'agentId') } function legacyCommitMessageCoreChanges( @@ -541,7 +541,7 @@ function mergeLegacyModelSelectionDelta( let changed = false const keys = new Set([...Object.keys(legacy ?? {}), ...Object.keys(projected ?? {})]) for (const key of keys) { - const legacyHasKey = Object.prototype.hasOwnProperty.call(legacy ?? {}, key) + const legacyHasKey = Object.hasOwn(legacy ?? {}, key) const legacyValue = legacy?.[key] if (JSON.stringify(projected?.[key]) === JSON.stringify(legacyValue)) { continue @@ -994,7 +994,7 @@ function hasOwnInstruction( instructions: Partial> | null | undefined, operation: SourceControlAiOperation ): boolean { - return Object.prototype.hasOwnProperty.call(instructions ?? {}, operation) + return Object.hasOwn(instructions ?? {}, operation) } function readRepoInstructionOverride( diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts index ec2ea0b9c64..d74af87eab2 100644 --- a/src/shared/terminal-file-link-conformance.ts +++ b/src/shared/terminal-file-link-conformance.ts @@ -13,7 +13,7 @@ export type TerminalFileLinkTapConformanceCase = { export function columnForTerminalFileLinkTap(testCase: TerminalFileLinkTapConformanceCase): number { const column = testCase.lineText.indexOf(testCase.tapText) - if (column < 0) { + if (column === -1) { throw new Error(`Tap text "${testCase.tapText}" not found in "${testCase.lineText}"`) } return column diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index d3d9b40226a..780164372cb 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -393,7 +393,7 @@ export function ingestGitGrepLine( const secondNullIdx = rest.indexOf('\0') let lineNumberText: string let lineContent: string - if (secondNullIdx >= 0) { + if (secondNullIdx !== -1) { lineNumberText = rest.substring(0, secondNullIdx) lineContent = rest.substring(secondNullIdx + 1).replace(/\n$/, '') } else { diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 06d490342ba..3437e44e560 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -331,7 +331,7 @@ export const TUI_AGENT_CONFIG: Record = { } export function isTuiAgent(value: unknown): value is TuiAgent { - return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TUI_AGENT_CONFIG, value) + return typeof value === 'string' && Object.hasOwn(TUI_AGENT_CONFIG, value) } export function getTuiAgentDetectCommands(config: TuiAgentConfig): string[] { diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 94ca6e103a1..f0f5d3e8bd1 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -85,7 +85,7 @@ export function resolveTuiAgentLaunchArgs( ): string { if ( configuredArgs && - Object.prototype.hasOwnProperty.call(configuredArgs, agent) && + Object.hasOwn(configuredArgs, agent) && typeof configuredArgs[agent] === 'string' ) { return configuredArgs[agent] ?? '' @@ -97,7 +97,7 @@ export function resolveTuiAgentLaunchEnv( agent: TuiAgent, configuredEnv: Partial>> | null | undefined ): Record { - if (configuredEnv && Object.prototype.hasOwnProperty.call(configuredEnv, agent)) { + if (configuredEnv && Object.hasOwn(configuredEnv, agent)) { return { ...configuredEnv[agent] } } return getTuiAgentDefaultEnv(agent) diff --git a/src/shared/types.ts b/src/shared/types.ts index 0f7a6685f9a..34cc0e43c35 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1497,8 +1497,8 @@ export type PRCheckJob = { export type PRCheckRunDetails = { name: string - status: PRCheckDetail['status'] | string | null - conclusion: PRCheckDetail['conclusion'] | string | null + status: PRCheckDetail['status'] | (string & {}) | null + conclusion: PRCheckDetail['conclusion'] | (string & {}) | null url: string | null detailsUrl: string | null startedAt: string | null @@ -1749,7 +1749,7 @@ export type LinearWorkspace = LinearViewer & { credentialRevision?: number } -export type LinearWorkspaceSelection = string | 'all' +export type LinearWorkspaceSelection = (string & {}) | 'all' export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined export type LinearConcreteWorkspaceId = string diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts index 9f58d453969..16f499ac7d5 100644 --- a/src/shared/workspace-session-schema.sleeping-agent.test.ts +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -219,12 +219,9 @@ describe('parseWorkspaceSession sleeping agents', () => { expect(result.ok).toBe(true) if (result.ok) { - expect( - Object.prototype.hasOwnProperty.call( - result.value.sleepingAgentSessionsByPaneKey ?? {}, - '__proto__' - ) - ).toBe(false) + expect(Object.hasOwn(result.value.sleepingAgentSessionsByPaneKey ?? {}, '__proto__')).toBe( + false + ) const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] expect(record?.agent).toBe('codex') expect(record?.launchConfig).toBeUndefined() diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 8c57b8e6a9f..9c72e8cdd4d 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -305,7 +305,7 @@ async function readPromptState(page: Page): Promise .filter((value): value is string => value !== null) const promptIndex = content.lastIndexOf(PROMPT) const liveLine = - promptIndex >= 0 ? (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '') : '' + promptIndex !== -1 ? (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '') : '' return { model: liveLine.trimEnd(), cursor: Array.from(liveLine.trimEnd()).length, diff --git a/tests/e2e/codex-composer-echo-latency-probe.ts b/tests/e2e/codex-composer-echo-latency-probe.ts index 9fc9cabf2c7..c49d98d3708 100644 --- a/tests/e2e/codex-composer-echo-latency-probe.ts +++ b/tests/e2e/codex-composer-echo-latency-probe.ts @@ -112,7 +112,7 @@ export async function installCodexEchoLatencyProbe(page: Page, target: string): const observeRender = (): void => { renderEvents += 1 const paintedAt = performance.now() - for (const entry of awaitingRender.splice(0, awaitingRender.length)) { + for (const entry of awaitingRender.splice(0)) { entry.sample.keyToRenderMs = paintedAt - entry.startedAt } } diff --git a/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts index b9bbb621597..730ebbeffc9 100644 --- a/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts +++ b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts @@ -62,7 +62,7 @@ const OBSERVER = { function readConfig(): FixtureConfig { const configIndex = process.argv.indexOf('--config') - const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined + const configPath = configIndex !== -1 ? process.argv[configIndex + 1] : undefined if (!configPath) { throw new Error('Legacy close client requires --config ') } diff --git a/tests/e2e/fixtures/daemon-generation-reconnect-client.ts b/tests/e2e/fixtures/daemon-generation-reconnect-client.ts index 28c0a6686a7..6bd163cc474 100644 --- a/tests/e2e/fixtures/daemon-generation-reconnect-client.ts +++ b/tests/e2e/fixtures/daemon-generation-reconnect-client.ts @@ -41,7 +41,7 @@ const MAX_OUTPUT_CHARS = 32_768 function readConfig(): ClientConfig { const configIndex = process.argv.indexOf('--config') - const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined + const configPath = configIndex !== -1 ? process.argv[configIndex + 1] : undefined if (!configPath) { throw new Error('Reconnect client requires --config ') } diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 3702ad21a9f..63b7683916a 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -12,7 +12,7 @@ const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') // at 120 reports/s) instead of minimal rows. const args = process.argv.slice(2) const logIndex = args.indexOf('--log') -const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const LOG_PATH = logIndex !== -1 ? args[logIndex + 1] : null const HEAVY_FRAMES = args.includes('--heavy') let offset = 0 diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index d1211234258..564f52e2f5f 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -86,7 +86,7 @@ export function readCompletedWorkerLedger(): LifecycleEvent[] { } const contents = readFileSync(lifecycleLedgerPath, 'utf8') const lastCompleteLine = contents.lastIndexOf('\n') - if (lastCompleteLine < 0) { + if (lastCompleteLine === -1) { return [] } return contents diff --git a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts index 208e79a8928..1ddfd56adb4 100644 --- a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts +++ b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts @@ -339,7 +339,7 @@ async function dispatchPlainEnter(session: CDPSession): Promise { async function readPromptLine(page: Page): Promise { const content = stripTerminalControls(await getTerminalContent(page, 20_000)) const promptIndex = content.lastIndexOf(PROMPT) - if (promptIndex < 0) { + if (promptIndex === -1) { return '' } return (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '').trimEnd() diff --git a/tests/e2e/remote-agent-session-focus-authority.spec.ts b/tests/e2e/remote-agent-session-focus-authority.spec.ts index 2b007fddd98..4fb4c3e7ae8 100644 --- a/tests/e2e/remote-agent-session-focus-authority.spec.ts +++ b/tests/e2e/remote-agent-session-focus-authority.spec.ts @@ -140,7 +140,7 @@ async function readRenderedTabOrder(page: Page): Promise { function expectImmediatelyAfter(order: string[], predecessor: string, created: string): void { const predecessorIndex = order.indexOf(predecessor) - if (predecessorIndex < 0) { + if (predecessorIndex === -1) { throw new Error( `placement predecessor ${predecessor} missing before ${created}: ${JSON.stringify(order)}` ) diff --git a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts index 70912388cba..ee5ebe81079 100644 --- a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts +++ b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts @@ -56,7 +56,7 @@ async function locateHoverProbe(page: Page, needle: string): Promise continue } const idx = line.translateToString(true).indexOf(needle) - if (idx >= 0) { + if (idx !== -1) { hit = { row, col: idx } break } diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 5df66cbce04..56c65a139aa 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -114,7 +114,7 @@ async function rightClickActiveTerminalSurface(page: Page): Promise { async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { await app.evaluate(({ BrowserWindow, ipcMain }) => { - type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => unknown const global = globalThis as unknown as { __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler } @@ -152,7 +152,7 @@ async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { await app.evaluate(({ ipcMain }) => { - type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => unknown const global = globalThis as unknown as { __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler } diff --git a/tests/tools/win-crash-survival-e2e/cli-args.mjs b/tests/tools/win-crash-survival-e2e/cli-args.mjs index 2a289d7893e..e3533342e4d 100644 --- a/tests/tools/win-crash-survival-e2e/cli-args.mjs +++ b/tests/tools/win-crash-survival-e2e/cli-args.mjs @@ -119,7 +119,7 @@ function validateArgShape(argv) { function takeValue(argv, flag) { const idx = argv.indexOf(flag) - if (idx < 0) { + if (idx === -1) { return undefined } const value = argv[idx + 1] diff --git a/tests/tools/win-update-e2e/cli-args.mjs b/tests/tools/win-update-e2e/cli-args.mjs index 510bac5a899..d6310e8d159 100644 --- a/tests/tools/win-update-e2e/cli-args.mjs +++ b/tests/tools/win-update-e2e/cli-args.mjs @@ -190,7 +190,7 @@ function validate(opts, installDirFlagPresent) { function takeValue(argv, flag) { const idx = argv.indexOf(flag) - if (idx < 0) { + if (idx === -1) { return undefined } const value = argv[idx + 1] diff --git a/tests/tools/win-update-e2e/daemon-processes.mjs b/tests/tools/win-update-e2e/daemon-processes.mjs index f7d43cc9b44..d292c0f24b8 100644 --- a/tests/tools/win-update-e2e/daemon-processes.mjs +++ b/tests/tools/win-update-e2e/daemon-processes.mjs @@ -148,7 +148,7 @@ function normalizeArray(raw) { function parseUserDataArg(argv) { const idx = argv.indexOf('--user-data') - return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() + return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() } function runStandalone(argv) { @@ -161,7 +161,7 @@ function runStandalone(argv) { console.log(JSON.stringify(pidFiles, null, 2)) const scopeIdx = argv.indexOf('--scope') - const scope = scopeIdx >= 0 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' + const scope = scopeIdx !== -1 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' const processes = findDaemonProcesses(scope) console.log( `[daemon-processes] live daemon processes${scope ? ` scoped to "${scope}"` : ''} (${processes.length}):` From ebcb2b6a60bc59d3cd4099e122584e2259931c92 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:29:01 -0700 Subject: [PATCH 016/109] docs: expand PR template with ELI5, before/after screenshots, and X handle Make the pull request template clearer for contributors: plain-language ELI5, what/why, mandatory before/after UI proof, testing checklist, AI disclosure, and an Author X field. Align CONTRIBUTING with the template. --- .github/CONTRIBUTING.md | 12 +++--- .github/pull_request_template.md | 67 ++++++++++++++++++++++++++------ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 31fd088d324..189428c47fe 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type ## Pull Requests -Each pull request should: +Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular: -- explain the user-visible change -- stay focused on a single topic when possible -- include screenshots or screen recordings for new UI or behavior changes +- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner) +- explain what changed and why, and stay focused on a single topic when possible +- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why - include high-quality tests when behavior changes or bug fixes warrant them - include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk - mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes -- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build). - -If there is no visual change, say that explicitly in the PR description. +- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build). ## Release Process diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b69e2dcc233..f8349076c63 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,59 @@ -## Summary +## ELI5 -Describe the user-visible change. + -## Screenshots +## What Changed -- Add screenshots or a screen recording for any new or changed UI behavior. -- If there is no visual change, say `No visual change`. + -## Testing +## Why -- [ ] `pnpm lint` -- [ ] `pnpm typecheck` -- [ ] `pnpm test` -- [ ] `pnpm build` -- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed + + +## Linked Issue + + + +- Fixes # + +## Screenshots (required for UI / behavior changes) + +**Before and after are mandatory** for any user-visible or UI change. Do not open a UI PR with only a description. + +- Attach a **before** screenshot (or short recording of the old behavior). +- Attach an **after** screenshot (or short recording of the new behavior). +- Side-by-side or labeled before/after is preferred. +- For motion, transitions, or interactions, include a short **before and after** video (or one video that clearly shows both). +- If there is truly no visual or interaction change, write exactly: `No visual change` and briefly say why. + +### Before + + + +### After + + + +## How to test + + + +- [ ] I manually tested these changes locally +- [ ] Automated tests added/updated, or explained why not below + +## Checklist + +- [ ] This PR is small and focused +- [ ] I explained what changed and why (including ELI5) +- [ ] Before/after screenshots or videos attached for UI changes, or `No visual change` with reason +- [ ] Self-reviewed for correctness, security, and performance +- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) +- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred) + +## AI assistance + +- [ ] This PR was created or substantially assisted by an AI coding agent (optional disclosure) +- [ ] I reviewed the full diff, understand every change, and can explain it in review ## AI Review Report @@ -27,3 +67,8 @@ Provide a basic security audit summary from your AI coding agent. Call out any i ## Notes Call out any platform-specific behavior, risks, or follow-up work. + +## Author + +- X / Twitter: @your_handle + From 444638d96b015eae81b83ff63dac5a3b160adb3e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:40:27 -0700 Subject: [PATCH 017/109] Revise pull request template for clarity and updates Updated the pull request template to simplify language and clarify sections. Added new sections for AI disclosure and testing instructions. --- .github/pull_request_template.md | 59 ++++++++++---------------------- 1 file changed, 19 insertions(+), 40 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f8349076c63..a1ba0f2376b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## ELI5 - + ## What Changed @@ -12,27 +12,15 @@ ## Linked Issue - + -- Fixes # +Fixes # -## Screenshots (required for UI / behavior changes) - -**Before and after are mandatory** for any user-visible or UI change. Do not open a UI PR with only a description. - -- Attach a **before** screenshot (or short recording of the old behavior). -- Attach an **after** screenshot (or short recording of the new behavior). -- Side-by-side or labeled before/after is preferred. -- For motion, transitions, or interactions, include a short **before and after** video (or one video that clearly shows both). -- If there is truly no visual or interaction change, write exactly: `No visual change` and briefly say why. - -### Before - - - -### After - - +## Visual Proof + + + + ## How to test @@ -41,34 +29,25 @@ - [ ] I manually tested these changes locally - [ ] Automated tests added/updated, or explained why not below + + +## AI Disclosure + + +## Review + +Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance + ## Checklist - [ ] This PR is small and focused - [ ] I explained what changed and why (including ELI5) -- [ ] Before/after screenshots or videos attached for UI changes, or `No visual change` with reason +- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason - [ ] Self-reviewed for correctness, security, and performance - [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) - [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred) -## AI assistance - -- [ ] This PR was created or substantially assisted by an AI coding agent (optional disclosure) -- [ ] I reviewed the full diff, understand every change, and can explain it in review - -## AI Review Report - -Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result. -Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR. - -## Security Audit - -Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed. - -## Notes - -Call out any platform-specific behavior, risks, or follow-up work. - ## Author - X / Twitter: @your_handle - + From 64aec94cb225eb44f59750f201f18f7958d81046 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:26:53 -0700 Subject: [PATCH 018/109] fix(sidebar): give draft reviews their own glyph instead of a red PR icon (#13919) * fix(sidebar): give draft reviews their own glyph instead of a red PR icon The worktree card review icon used one PR glyph for every state and tinted it by check status, so a draft with failing checks rendered as a red PR icon that read as closed. Shape now carries review state (draft/closed/merged), and check tone applies only to open reviews so the glyph agrees with its tooltip. Same fix for the source-control header icon, where draft and closed differed only by a muted tone. Closes #13088 Co-authored-by: Orca * test(sidebar): pin stateless and GitLab-closed review icon behaviour Documents why a stateless row keeps its check tone (folder cards render one while a linked review loads or its details fail) and covers the closed-MR glyph override, which previously read as already-merged. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../hosted-review-header-chrome.tsx | 22 ++++- .../sidebar/worktree-review-helpers.test.tsx | 81 ++++++++++++++++++ .../sidebar/worktree-review-helpers.tsx | 84 +++++++++++++------ 3 files changed, 160 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx index 66733779c77..b13a5728d2f 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx +++ b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { GitMerge } from 'lucide-react' +import { GitMerge, GitPullRequestClosed, GitPullRequestDraft } from 'lucide-react' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { cn } from '@/lib/utils' import { PullRequestIcon } from './checks-panel-content' @@ -17,6 +17,23 @@ function hostedReviewStateClass(review: HostedReviewInfo): string { return 'text-muted-foreground/50' } +// Why: draft and closed only differed by a near-identical muted tone on the same +// glyph, so a draft review read as closed. +function hostedReviewStateIcon( + review: HostedReviewInfo +): React.ComponentType<{ className?: string }> | null { + if (review.state === 'merged') { + return GitMerge + } + if (review.state === 'closed') { + return GitPullRequestClosed + } + if (review.state === 'draft') { + return GitPullRequestDraft + } + return null +} + export function HostedReviewIcon({ review, className @@ -24,7 +41,8 @@ export function HostedReviewIcon({ review: HostedReviewInfo className?: string }): React.JSX.Element { - const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon + const providerIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon + const Icon = hostedReviewStateIcon(review) ?? providerIcon return } diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx index 7ca741d4ced..2be58e1bff5 100644 --- a/src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx @@ -44,4 +44,85 @@ describe('ReviewIcon', () => { expect(blocked).not.toContain('text-emerald-500/80') expect(blocked).not.toBe(passing) }) + + // Why: #13088 — a draft with failing checks used the same glyph as every other + // review and was painted red, so it was indistinguishable from a closed PR. + it('gives a draft its own glyph and never paints it with a check tone', () => { + const draft = renderToStaticMarkup( + + ) + const closed = renderToStaticMarkup( + + ) + + expect(draft).toContain('lucide-git-pull-request-draft') + expect(draft).not.toContain('text-rose-500/85') + expect(closed).toContain('lucide-git-pull-request-closed') + expect(draft).not.toBe(closed) + }) + + it('keeps check tones on open reviews so failing checks still stand out', () => { + const failing = renderToStaticMarkup( + + ) + + expect(failing).toContain('text-rose-500/85') + }) + + it('renders merged reviews with the merge glyph', () => { + const merged = renderToStaticMarkup( + + ) + + expect(merged).toContain('lucide-git-merge') + }) + + // Why: a closed MR used to render the merge glyph, which read as "already merged". + it('overrides the GitLab provider glyph for closed merge requests', () => { + const closed = renderToStaticMarkup( + + ) + + expect(closed).toContain('lucide-git-pull-request-closed') + expect(closed).not.toContain('lucide-git-merge') + }) + + // Why: folder cards render a stateless row while a linked review loads or its + // details fail. There is no state glyph to contradict, so problems must still be + // visible — but a tone-less row must not be promoted to a passing green. + it('flags problems on a stateless row without ever claiming success', () => { + const stateless = (status: 'failure' | 'pending' | 'success') => + renderToStaticMarkup( + + ) + + expect(stateless('failure')).toContain('text-rose-500/85') + expect(stateless('pending')).toContain('text-amber-500/85') + expect(stateless('success')).not.toContain('text-emerald-500/80') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx index 7f096a954dc..96e64fb4423 100644 --- a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx @@ -1,4 +1,4 @@ -import { GitMerge } from 'lucide-react' +import { GitMerge, GitPullRequestClosed, GitPullRequestDraft } from 'lucide-react' import { cn } from '@/lib/utils' import { PullRequestIcon } from './WorktreeCardHelpers' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' @@ -23,6 +23,60 @@ export function getProviderName(review: WorktreeCardPrDisplay): string { return 'GitHub' } +// Why: the glyph must carry review state — tone alone made a draft with failing +// checks render as a red PR icon, which reads as closed. +function getStateIcon( + state: WorktreeCardPrDisplay['state'] +): React.ComponentType<{ className?: string }> | null { + if (state === 'merged') { + return GitMerge + } + if (state === 'closed') { + return GitPullRequestClosed + } + if (state === 'draft') { + return GitPullRequestDraft + } + return null +} + +// Why: checks only gate a review that is actually open; draft/closed/merged keep +// their state tone so the glyph agrees with its tooltip. A stateless row (folder +// cards render one while a linked review is loading or its details failed) has no +// state glyph to contradict, so it still flags problems — but never claims success, +// since emerald would assert an open review we have not confirmed. +function getCheckTone(review: WorktreeCardPrDisplay): string | null { + if (review.state && review.state !== 'open') { + return null + } + if (review.status === 'failure') { + return 'text-rose-500/85' + } + if (review.status === 'pending') { + return 'text-amber-500/85' + } + if (review.state === 'open' && review.status === 'success') { + return 'text-emerald-500/80' + } + return null +} + +function getStateTone(state: WorktreeCardPrDisplay['state']): string { + if (state === 'merged') { + return 'text-purple-600/70 dark:text-purple-400/70' + } + if (state === 'open') { + return 'text-emerald-500/80' + } + if (state === 'closed') { + return 'text-muted-foreground/60' + } + if (state === 'draft') { + return 'text-muted-foreground/50' + } + return 'text-muted-foreground opacity-70' +} + export function ReviewIcon({ review, className, @@ -32,28 +86,8 @@ export function ReviewIcon({ className?: string variant?: 'provider' | 'generic' }): React.JSX.Element { - const Icon = variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon - const checkTone = - review.state !== 'merged' && review.status === 'failure' - ? 'text-rose-500/85' - : review.state !== 'merged' && review.status === 'pending' - ? 'text-amber-500/85' - : review.state === 'open' && review.status === 'success' - ? 'text-emerald-500/80' - : null - return ( - - ) + const providerIcon = + variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon + const Icon = getStateIcon(review.state) ?? providerIcon + return } From b51ef400ec74b3dc761809b7cd4a8ef7489e96dd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:53:22 -0700 Subject: [PATCH 019/109] fix(quick-commands): flatten the settings list and give the command editor room (#13922) --- .../components/settings/QuickCommandsList.tsx | 208 +++++++++++------- .../components/settings/QuickCommandsPane.tsx | 93 +++----- .../settings/QuickCommandsToolbar.tsx | 116 ++++++++++ .../TerminalQuickCommandAdvancedSection.tsx | 57 ++--- .../TerminalQuickCommandAppendEnterSwitch.tsx | 5 +- .../TerminalQuickCommandCollapsibleRow.tsx | 38 ++++ .../TerminalQuickCommandContentSection.tsx | 188 +++++++--------- .../TerminalQuickCommandDialog.tsx | 22 +- .../TerminalQuickCommandDialogFooter.tsx | 10 +- src/renderer/src/components/ui/textarea.tsx | 28 +++ src/renderer/src/i18n/locales/en.json | 6 + 11 files changed, 470 insertions(+), 301 deletions(-) create mode 100644 src/renderer/src/components/settings/QuickCommandsToolbar.tsx create mode 100644 src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandCollapsibleRow.tsx create mode 100644 src/renderer/src/components/ui/textarea.tsx diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx index ec377677ee5..3a0042b32a8 100644 --- a/src/renderer/src/components/settings/QuickCommandsList.tsx +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, Pencil, Trash2 } from 'lucide-react' +import { Check, Copy, Pencil, TerminalSquare, Trash2 } from 'lucide-react' import type { Repo, TerminalQuickCommand, @@ -29,6 +29,15 @@ function getScopeLabel( return repo ? getQuickCommandRepoLabel(repo) : 'Missing project' } +function getRunModeLabel(command: TerminalQuickCommand): string { + if (isTerminalAgentQuickCommand(command)) { + return translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent') + } + return command.appendEnter + ? translate('auto.components.settings.QuickCommandsPane.9b3e338d62', 'Enter') + : translate('auto.components.settings.QuickCommandsPane.9fcfc29519', 'Insert') +} + function QuickCommandRow({ command, repoById, @@ -43,6 +52,7 @@ function QuickCommandRow({ const scope = getTerminalQuickCommandScope(command) const body = getTerminalQuickCommandBody(command) const { canCopy, copyText, status } = useClipboardTextCopyFeedback(body) + const commandName = command.label || 'quick command' const copyLabel = status === 'copied' @@ -51,19 +61,24 @@ function QuickCommandRow({ ? translate('auto.components.settings.QuickCommandsPane.53b17a4b1b', "Couldn't copy") : canCopy ? translate('auto.components.settings.QuickCommandsPane.a9a564b7e7', 'Copy {{value0}}', { - value0: command.label || 'quick command' + value0: commandName }) : translate('auto.components.settings.QuickCommandsPane.69a1441a21', 'Nothing to copy') + const editLabel = translate( + 'auto.components.settings.QuickCommandsPane.7d90fd5299', + 'Edit {{value0}}', + { value0: commandName } + ) return ( -
+
{command.label || translate('auto.components.settings.QuickCommandsPane.2bb9e38e93', 'Untitled')}
- + {scope.type === 'repo' ? ( <> @@ -74,9 +89,9 @@ function QuickCommandRow({ )}
-
+
{isTerminalAgentQuickCommand(command) ? ( - + ) : null} @@ -91,59 +106,95 @@ function QuickCommandRow({
-
- {isTerminalAgentQuickCommand(command) - ? translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent') - : command.appendEnter - ? translate('auto.components.settings.QuickCommandsPane.9b3e338d62', 'Enter') - : translate('auto.components.settings.QuickCommandsPane.9fcfc29519', 'Insert')} +
+ {getRunModeLabel(command)} +
+ {/* Why can-hover: touch devices never hover, so the actions must stay visible there. */} +
+ + + +
+
+ ) +} + +function QuickCommandsEmptyState({ + hasCommands, + hasQuery +}: { + hasCommands: boolean + hasQuery: boolean +}): React.JSX.Element { + if (hasCommands) { + return ( +
+ {hasQuery + ? translate( + 'auto.components.settings.QuickCommandsList.noSearchMatches', + 'No commands match this search.' + ) + : translate( + 'auto.components.settings.QuickCommandsPane.3eb9897ab0', + 'No commands in the selected scopes.' + )} +
+ ) + } + // Why no action here: the toolbar's Add Command sits directly above. + return ( +
+ +
+

+ {translate( + 'auto.components.settings.QuickCommandsPane.38d61927e6', + 'No quick commands saved.' + )} +

+

+ {translate( + 'auto.components.settings.QuickCommandsPane.c36912efd5', + 'Run them from the Quick Commands button in the tab bar, or right-click inside any terminal.' + )} +

- - -
) } @@ -151,43 +202,32 @@ function QuickCommandRow({ export function QuickCommandsList({ commands, visibleCommands, + hasQuery, repoById, onEdit, onRemove }: { commands: TerminalQuickCommand[] visibleCommands: TerminalQuickCommand[] + hasQuery: boolean repoById: Map> onEdit: (command: TerminalQuickCommand) => void onRemove: (command: TerminalQuickCommand) => void }): React.JSX.Element { + if (visibleCommands.length === 0) { + return 0} hasQuery={hasQuery} /> + } return ( -
- {visibleCommands.length === 0 ? ( -
- {commands.length === 0 - ? translate( - 'auto.components.settings.QuickCommandsPane.38d61927e6', - 'No quick commands saved.' - ) - : translate( - 'auto.components.settings.QuickCommandsPane.3eb9897ab0', - 'No commands in the selected scopes.' - )} -
- ) : ( -
- {visibleCommands.map((command) => ( - - ))} -
- )} +
+ {visibleCommands.map((command) => ( + + ))}
) } diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index f7f703e7ac1..a4f1ea85fe4 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -1,26 +1,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Plus } from 'lucide-react' import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types' import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' +import { searchTerminalQuickCommands } from '@/lib/terminal-quick-command-search' import { useAppStore } from '../../store' import { Button } from '../ui/button' -import { Label } from '../ui/label' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' import { QuickCommandsList } from './QuickCommandsList' -import { GLOBAL_SCOPE_KEY, QuickCommandsScopeFilter } from './QuickCommandsScopeFilter' +import { QuickCommandsToolbar } from './QuickCommandsToolbar' +import { GLOBAL_SCOPE_KEY } from './QuickCommandsScopeFilter' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { getTerminalQuickCommandHostOptions, shouldShowTerminalQuickCommandHostOwnership @@ -130,6 +129,7 @@ export function QuickCommandsPane({ // automatically rather than being silently excluded. const [scopeSelection, setScopeSelection] = useState | null>(null) const [scopePopoverOpen, setScopePopoverOpen] = useState(false) + const [query, setQuery] = useState('') const availableHostId = getAvailableQuickCommandHostId(selectedHostId, hostOptions) const editorHostIsCurrent = @@ -164,7 +164,7 @@ export function QuickCommandsPane({ const effectiveSelection: ReadonlySet = scopeSelection ?? allScopeKeys const showAll = scopeSelection === null - const visibleCommands = commands.filter((command) => { + const scopedCommands = commands.filter((command) => { const scope = getTerminalQuickCommandScope(command) if (showAll) { return true @@ -174,6 +174,7 @@ export function QuickCommandsPane({ } return effectiveSelection.has(scope.repoId) }) + const visibleCommands = searchTerminalQuickCommands(scopedCommands, query) const createDraftForCurrentFilter = useCallback((): TerminalQuickCommand => { // Why: when the user has narrowed to a single repo scope, the natural @@ -273,68 +274,32 @@ export function QuickCommandsPane({ void useAppStore.getState().deleteTerminalQuickCommand(selectedHostId, command.id) } - return ( -
-
-
- -

- {shouldShowTerminalQuickCommandHostOwnership(hostOptions) - ? ownership.description - : translate( - 'auto.components.settings.settingOwnership.terminalQuickCommands', - 'Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.' - )} -

-
- -
+ const openAddDialog = (): void => + setEditor({ + mode: 'add', + command: createDraftForCurrentFilter(), + connectionGeneration: selectedRuntimeConnectionGeneration, + hostId: selectedHostId + }) + return ( +
{shouldShowTerminalQuickCommandHostOwnership(hostOptions) ? ( -
- - -
+

{ownership.description}

) : null} - { + setSelectedHostId(hostId) + setScopeSelection(null) + }} + canAdd={canManageSelectedHost} + onAdd={openAddDialog} repos={hostRepos} effectiveSelection={effectiveSelection} showAll={showAll} @@ -408,6 +373,7 @@ export function QuickCommandsPane({ 0} repoById={repoById} onEdit={(command) => setEditor({ @@ -428,6 +394,7 @@ export function QuickCommandsPane({ mode={editor.mode} command={editor.command} repos={hostRepos} + defaultAdvancedOpen onOpenChange={(open) => !open && setEditor(null)} onSave={saveCommand} /> diff --git a/src/renderer/src/components/settings/QuickCommandsToolbar.tsx b/src/renderer/src/components/settings/QuickCommandsToolbar.tsx new file mode 100644 index 00000000000..65e034eeb3f --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsToolbar.tsx @@ -0,0 +1,116 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Plus, Search } from 'lucide-react' +import type { Repo } from '../../../../shared/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { translate } from '@/i18n/i18n' +import { QuickCommandsScopeFilter } from './QuickCommandsScopeFilter' + +type QuickCommandsToolbarProps = { + query: string + setQuery: (query: string) => void + hostOptions: readonly { id: ExecutionHostId; label: string }[] + showHostSelect: boolean + selectedHostId: ExecutionHostId + onHostChange: (hostId: ExecutionHostId) => void + canAdd: boolean + onAdd: () => void + repos: readonly Repo[] + effectiveSelection: ReadonlySet + showAll: boolean + scopePopoverOpen: boolean + setScopePopoverOpen: Dispatch> + handleSelectAll: () => void + toggleScope: (key: string) => void +} + +export function QuickCommandsToolbar({ + query, + setQuery, + hostOptions, + showHostSelect, + selectedHostId, + onHostChange, + canAdd, + onAdd, + repos, + effectiveSelection, + showAll, + scopePopoverOpen, + setScopePopoverOpen, + handleSelectAll, + toggleScope +}: QuickCommandsToolbarProps): React.JSX.Element { + const searchLabel = translate( + 'auto.components.settings.QuickCommandsToolbar.searchLabel', + 'Search commands' + ) + + return ( +
+
+ + setQuery(event.target.value)} + placeholder={searchLabel} + aria-label={searchLabel} + className="h-8 pl-8 text-xs" + /> +
+ + + + {showHostSelect ? ( + + ) : null} + + +
+ ) +} diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx index d49be9c9616..af196b2c628 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch' +import { TerminalQuickCommandCollapsibleRow } from './TerminalQuickCommandCollapsibleRow' import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField' type TerminalQuickCommandAdvancedSectionProps = { @@ -50,42 +51,26 @@ export function TerminalQuickCommandAdvancedSection({ -
-
-
- {!isTerminalAgentQuickCommand(draft) ? ( - - ) : null} - { - lastRepoScopeIdRef.current = repoId - }} - setDraft={setDraft} - /> -
-
-
+ + { + lastRepoScopeIdRef.current = repoId + }} + setDraft={setDraft} + /> + {!isTerminalAgentQuickCommand(draft) ? ( + + ) : null} +
) } diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx index 4450562729e..73f69ebf5d8 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx @@ -3,11 +3,13 @@ import { Switch } from '@/components/ui/switch' type TerminalQuickCommandAppendEnterSwitchProps = { appendEnter: boolean onToggle: () => void + disabled?: boolean } export function TerminalQuickCommandAppendEnterSwitch({ appendEnter, - onToggle + onToggle, + disabled = false }: TerminalQuickCommandAppendEnterSwitchProps): React.JSX.Element { return (
@@ -27,6 +29,7 @@ export function TerminalQuickCommandAppendEnterSwitch({
+
+
+ {children} +
+
+
+ ) +} diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx index 34035a6233f..19967b0daba 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx @@ -12,9 +12,11 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { TerminalQuickCommandCollapsibleRow } from './TerminalQuickCommandCollapsibleRow' import { getTerminalQuickCommandAgentOptions } from './terminal-quick-command-agent-options' import type { TerminalQuickCommandDialogDraftMemory } from './terminal-quick-command-dialog-draft' @@ -37,85 +39,66 @@ export function TerminalQuickCommandContentSection({ }: TerminalQuickCommandContentSectionProps): React.JSX.Element { return (
- {/* Why: action changes add/remove agent-only fields; animating rows here - keeps the fixed dialog from snapping between content heights. */} -
-
-
-
-
-
+ ) : null} + + + + ) + })} + + +
-