From 9b3ed3997e9a6f4d90735f55500d134d0b36c0f4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:45:55 -0700 Subject: [PATCH 01/26] fix(daemon): resolve Unix shell fallback before node-pty spawn (#8306) * fix: resolve daemon shell before spawning * fix(daemon): reconcile shell-ready barrier with the resolved fallback shell The adapter computes shellReadySupported from the preferred shell before spawn. When the daemon falls back (e.g. to /bin/sh), the session would queue startup commands for the full 15s ready-marker timeout and wrap multiline prompts in bracketed-paste markers the fallback shell cannot parse. Expose the spawned shell on SubprocessHandle and downgrade the barrier and paste wrapping in TerminalHost when the actual shell cannot emit the marker. Also apply the Unix shell fallback after the relay env scrub so a scrubbed SHELL cannot drop the corrected value, and cover the resolve- before-launch-config ordering, the no-shell throw, and candidate dedup with tests. Co-authored-by: Orca --------- Co-authored-by: Vitus Apollo Co-authored-by: Orca --- src/main/daemon/pty-subprocess.test.ts | 116 +++++++++++++++++++++ src/main/daemon/pty-subprocess.ts | 13 +++ src/main/daemon/session.ts | 4 + src/main/daemon/shell-ready.ts | 9 +- src/main/daemon/terminal-host.test.ts | 85 ++++++++++++++- src/main/daemon/terminal-host.ts | 15 ++- src/main/providers/local-pty-utils.test.ts | 48 ++++++++- src/main/providers/local-pty-utils.ts | 25 ++++- 8 files changed, 306 insertions(+), 9 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ca21dd5e58b..99a35047743 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -9,10 +9,12 @@ const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock, + resolveUnixShellPathMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), resolveAgentForegroundProcessMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { @@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) @@ -116,6 +119,8 @@ describe('createPtySubprocess', () => { async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) validateWorkingDirectoryMock.mockClear() + resolveUnixShellPathMock.mockReset() + resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath) isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] @@ -181,6 +186,117 @@ describe('createPtySubprocess', () => { ) }) + it('resolves a missing Unix default before spawning node-pty', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + + expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh') + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-l'], + expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) }) + ) + expect(warn).toHaveBeenCalledWith( + '[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"' + ) + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + } + }) + + it('derives shell-ready launch config from the resolved fallback shell', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const previousMarker = process.env.ORCA_SHELL_READY_MARKER + const previousZdotdir = process.env.ZDOTDIR + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + // Why: the test runner itself can execute inside an Orca-wrapped shell + // whose exported wrapper vars would leak through the process.env spread. + delete process.env.ORCA_SHELL_READY_MARKER + delete process.env.ZDOTDIR + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: {}, + command: 'echo hi' + }) + + expect(handle.shellPath).toBe('/bin/sh') + const [shellPath, shellArgs, spawnOptions] = spawnMock.mock.calls[0] + expect(shellPath).toBe('/bin/sh') + expect(shellArgs).toEqual(['-l']) + // A launch config derived from the missing preferred zsh would inject + // ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them. + expect(spawnOptions.env.ZDOTDIR).toBeUndefined() + expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(spawnOptions.env.SHELL).toBe('/bin/sh') + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + if (previousMarker === undefined) { + delete process.env.ORCA_SHELL_READY_MARKER + } else { + process.env.ORCA_SHELL_READY_MARKER = previousMarker + } + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + } + }) + + it('surfaces the no-executable-shell error before node-pty forks', () => { + resolveUnixShellPathMock.mockImplementation(() => { + throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)') + }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })).toThrow( + 'No executable Unix shell found' + ) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6a285301406..ce1b1faf35c 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, + resolveUnixShellPath, validateWorkingDirectory } from '../providers/local-pty-utils' import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' @@ -742,6 +743,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (opts.env?.TERM) { env.TERM = opts.env.TERM } + // Why after the scrub: SHELL must reflect the shell that actually spawns, + // matching LocalPtyProvider; and before the launch-config derivation below + // so shell-ready wrappers target the resolved shell, not a missing one. + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } // Why: OpenCode/Codex path restoration and OMP's typed-command status // wrapper need shell-ready code after user startup files run. let shellLaunch: ReturnType | null = null @@ -1007,6 +1019,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + shellPath, ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 06404fd9246..b9c82a48174 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -48,6 +48,10 @@ export type SubprocessHandle = { /** True when shell launch args already delivered the startup command, so the * terminal host must skip its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean + /** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The + * host reconciles the caller's shell-ready assumption against it so a + * fallback shell without a ready marker never gates startup commands. */ + shellPath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 909a75ddf1f..d602f5f7fa5 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -348,13 +348,16 @@ export function resolvePtyShellPath(env: Record): string { return env.SHELL || process.env.SHELL || '/bin/zsh' } +export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean { + const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() + return shellName === 'zsh' || shellName === 'bash' +} + export function supportsPtyStartupBarrier(env: Record): boolean { if (process.platform === 'win32') { return false } - const resolvedShell = resolvePtyShellPath(env) - const shellName = pathWin32.basename(basename(resolvedShell)).toLowerCase() - return shellName === 'zsh' || shellName === 'bash' + return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) } type ShellLaunchConfig = { diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f92d4c1e1f..46e495e5f2e 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,7 +3,7 @@ import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' function createMockSubprocess( - options: { startupCommandDeliveredInShellArgs?: boolean } = {} + options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {} ): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null @@ -12,6 +12,7 @@ function createMockSubprocess( ...(options.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), + ...(options.shellPath ? { shellPath: options.shellPath } : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -193,6 +194,88 @@ describe('TerminalHost', () => { } }) + it('delivers startup commands immediately when the spawned shell cannot emit the ready marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) + }) + + it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'claude "line one\nline two"', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] + expect(written).not.toContain('\x1b[200~') + expect(written).toContain('line one\nline two') + }) + + it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/bash' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) + it('does not write startup commands already embedded in shell args', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 203719d354e..b15420fed14 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,5 +1,6 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' @@ -104,6 +105,16 @@ export class TerminalHost { terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) + // Why: the caller computed shellReadySupported from the preferred shell, + // before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready + // marker, so keeping the stale flag would queue startup commands until the + // shell-ready timeout and bracketed-paste-wrap them for a line editor + // without paste mode. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || + shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ sessionId: opts.sessionId, cols: size.cols, @@ -111,7 +122,7 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, - shellReadySupported: opts.shellReadySupported ?? false, + shellReadySupported, historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's @@ -143,7 +154,7 @@ export class TerminalHost { session.write( buildStartupCommandSubmission(opts.command, { submit, - bracketedPasteSafe: opts.shellReadySupported ?? false + bracketedPasteSafe: shellReadySupported }) ) } diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 66fde349ded..0527922f4cc 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock })) -import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { + resolveUnixShellPath, + spawnShellWithFallback, + validateWorkingDirectory +} from './local-pty-utils' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' const NATIVE_DIR = 'C:\\Users\\jin\\repo' @@ -102,6 +106,48 @@ describe('validateWorkingDirectory', () => { }) }) +describe('resolveUnixShellPath', () => { + beforeEach(() => { + existsSyncMock.mockReset() + accessSyncMock.mockReset() + }) + + it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { + expect(resolveUnixShellPath('bash')).toBe('bash') + expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('selects an executable fallback before node-pty can fork a missing shell', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ + '/missing/zsh', + '/bin/zsh', + '/bin/bash', + '/bin/sh' + ]) + }) + + it('reports every attempted shell when no executable candidate exists', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( + 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' + ) + }) + + it('dedupes a preferred shell that is already a fallback candidate', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh']) + }) +}) + describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 53d016d312f..3e21095d535 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,4 +1,4 @@ -import { basename, join } from 'node:path' +import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' @@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' let didEnsureSpawnHelperExecutable = false +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + function toUnpackedAsarPath(candidate: string): string { return candidate .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') @@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null { return null } +/** + * Resolves an absolute Unix shell before node-pty forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} + /** * Ensure the node-pty spawn-helper binary has the executable bit set. * @@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu // Try fallback shells on Unix if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) for (const fallback of fallbackShells) { if (getShellValidationError(fallback)) { continue From 9202f92220d93113eaf8902ce52e42bf62960f75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:57:58 +0000 Subject: [PATCH 02/26] release: v1.4.136-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f5400d484e..5cb55814377 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.136-rc.0", + "version": "1.4.136-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From e538a93129b04f587e8b49cd71c8bb25e3a757c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:31:32 -0700 Subject: [PATCH 03/26] Cap Kimi session index cache (#7673) --- .../session-scanner-kimi-index-cache.test.ts | 164 ++++++++++++++++++ .../session-scanner-kimi-index-cache.ts | 132 ++++++++++++++ .../session-scanner-kimi-parser.test.ts | 37 +++- .../ai-vault/session-scanner-kimi-paths.ts | 48 +++-- 4 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-kimi-index-cache.test.ts create mode 100644 src/main/ai-vault/session-scanner-kimi-index-cache.ts diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts new file mode 100644 index 00000000000..549c1a3c170 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS, + type KimiSessionIndexIdentity +} from './session-scanner-kimi-index-cache' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' + +const IDENTITY: KimiSessionIndexIdentity = { + changeTimeMs: 1, + mtimeMs: 1, + sizeBytes: 1 +} +let tempDirs: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + clearKimiSessionIndexCache() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('KimiSessionIndexCache', () => { + it('bounds prolonged path churn and retains a reused index', async () => { + const cache = new KimiSessionIndexCache() + + for (let index = 0; index < KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + const reusedPath = 'home-0/session_index.jsonl' + await cache.get(reusedPath, IDENTITY, cache.beginRead(), async () => new Map()) + + const firstOverflowPath = `home-${KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS}/session_index.jsonl` + await cache.get( + firstOverflowPath, + IDENTITY, + cache.beginRead(), + async () => new Map([[firstOverflowPath, firstOverflowPath]]) + ) + expect(cache.has(reusedPath)).toBe(true) + expect(cache.has('home-1/session_index.jsonl')).toBe(false) + + for (let index = KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS + 1; index < 640; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + + expect(cache.has(reusedPath)).toBe(false) + expect(cache.size).toBe(KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) + expect(cache.has('home-576/session_index.jsonl')).toBe(true) + expect(cache.has('home-575/session_index.jsonl')).toBe(false) + cache.clear() + }) + + it('refreshes active entries and expires them after an idle TTL', async () => { + vi.useFakeTimers() + const cache = new KimiSessionIndexCache() + const path = 'active/session_index.jsonl' + const value = new Map([['session', '/repo']]) + + await cache.get(path, IDENTITY, cache.beginRead(), async () => value) + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map())).toBe(value) + + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(cache.has(path)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + expect(cache.has(path)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates concurrent reads of the same file identity', async () => { + const cache = new KimiSessionIndexCache() + const load = vi.fn(async () => new Map([['session', '/repo']])) + const first = cache.get('index', IDENTITY, cache.beginRead(), load) + const second = cache.get('index', IDENTITY, cache.beginRead(), load) + + expect(second).toBe(first) + await expect(second).resolves.toEqual(new Map([['session', '/repo']])) + expect(load).toHaveBeenCalledOnce() + cache.clear() + }) + + it('does not let an older mutation race replace a newer identity', async () => { + const cache = new KimiSessionIndexCache() + const oldGeneration = cache.beginRead() + const newGeneration = cache.beginRead() + const newerIdentity = { ...IDENTITY, changeTimeMs: 2, mtimeMs: 2, sizeBytes: 2 } + const newer = new Map([['session', '/new']]) + + await cache.get('index', newerIdentity, newGeneration, async () => newer) + await cache.get('index', IDENTITY, oldGeneration, async () => new Map([['session', '/old']])) + const reload = vi.fn(async () => new Map()) + + await expect(cache.get('index', newerIdentity, cache.beginRead(), reload)).resolves.toBe(newer) + expect(reload).not.toHaveBeenCalled() + cache.clear() + }) + + it('does not repopulate after an owner clears an in-flight read', async () => { + const cache = new KimiSessionIndexCache() + const staleGeneration = cache.beginRead() + cache.clear() + + await cache.get('index', IDENTITY, staleGeneration, async () => new Map([['session', '/old']])) + + expect(cache.has('index')).toBe(false) + const current = new Map([['session', '/current']]) + await expect( + cache.get('index', IDENTITY, cache.beginRead(), async () => current) + ).resolves.toBe(current) + expect(cache.has('index')).toBe(true) + cache.clear() + }) +}) + +describe('Kimi session index reader cache', () => { + it('releases a retained map when its index file disappears', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session', workDir: '/repo' })}\n`) + + await readKimiWorkDirBySessionId(indexPath) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(true) + await rm(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(false) + }) + + it('invalidates when size changes even if the mtime is restored', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-mutation-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n`) + const originalStat = await stat(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([['session-old', '/old']]) + ) + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n${JSON.stringify({ sessionId: 'session-new', workDir: '/new' })}\n` + ) + await utimes(indexPath, originalStat.atime, originalStat.mtime) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([ + ['session-old', '/old'], + ['session-new', '/new'] + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts new file mode 100644 index 00000000000..13ebe3c1426 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -0,0 +1,132 @@ +export type KimiSessionIndexIdentity = { + changeTimeMs: number + mtimeMs: number + sizeBytes: number +} + +type KimiSessionIndexCacheEntry = { + expiresAt: number + generation: number + identity: KimiSessionIndexIdentity + timer: NodeJS.Timeout | null + value: Promise> +} + +export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 +// Active Vault scans refresh this window; closing the surface releases parsed +// index maps soon without making a live Kimi session reread on every scan. +export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 + +export class KimiSessionIndexCache { + private readonly entries = new Map() + private minimumCacheGeneration = 0 + private nextGeneration = 0 + + beginRead(): number { + this.nextGeneration += 1 + return this.nextGeneration + } + + clear(): void { + for (const entry of this.entries.values()) { + if (entry.timer) { + clearTimeout(entry.timer) + } + } + this.entries.clear() + // Why: a read already awaiting stat/load when its owner clears the cache + // may finish later, but must not silently recreate the released entry. + this.minimumCacheGeneration = this.nextGeneration + 1 + } + + delete(indexPath: string, generation = Number.POSITIVE_INFINITY): void { + const entry = this.entries.get(indexPath) + if (entry && entry.generation <= generation) { + this.forget(indexPath, entry) + } + } + + get( + indexPath: string, + identity: KimiSessionIndexIdentity, + generation: number, + load: () => Promise> + ): Promise> { + if (generation < this.minimumCacheGeneration) { + return load() + } + const cached = this.entries.get(indexPath) + const now = Date.now() + if (cached && cached.expiresAt > now && identitiesMatch(cached.identity, identity)) { + this.remember(indexPath, cached, now) + return cached.value + } + if (cached && cached.generation > generation) { + // Why: a slower, older stat must not replace a newer file generation + // that another concurrent scan already cached for the same path. + return load() + } + + const entry: KimiSessionIndexCacheEntry = { + expiresAt: now + KIMI_WORK_DIR_CACHE_TTL_MS, + generation, + identity, + timer: null, + value: load() + } + this.remember(indexPath, entry, now) + return entry.value + } + + has(indexPath: string): boolean { + return this.entries.has(indexPath) + } + + get size(): number { + return this.entries.size + } + + private forget(indexPath: string, entry: KimiSessionIndexCacheEntry): void { + if (this.entries.get(indexPath) !== entry) { + return + } + if (entry.timer) { + clearTimeout(entry.timer) + } + this.entries.delete(indexPath) + } + + private remember(indexPath: string, entry: KimiSessionIndexCacheEntry, now: number): void { + const replaced = this.entries.get(indexPath) + if (replaced?.timer && replaced !== entry) { + clearTimeout(replaced.timer) + } + if (entry.timer) { + clearTimeout(entry.timer) + } + entry.expiresAt = now + KIMI_WORK_DIR_CACHE_TTL_MS + entry.timer = setTimeout(() => this.forget(indexPath, entry), KIMI_WORK_DIR_CACHE_TTL_MS) + entry.timer.unref() + this.entries.delete(indexPath) + this.entries.set(indexPath, entry) + + while (this.entries.size > KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) { + const oldest = this.entries.entries().next().value + if (!oldest) { + return + } + this.forget(oldest[0], oldest[1]) + } + } +} + +function identitiesMatch( + left: KimiSessionIndexIdentity, + right: KimiSessionIndexIdentity +): boolean { + return ( + left.changeTimeMs === right.changeTimeMs && + left.mtimeMs === right.mtimeMs && + left.sizeBytes === right.sizeBytes + ) +} diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index bb268fbdcb1..67748ae727f 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' import type { FileWithMtime } from './session-scanner-types' let tempDirs: string[] = [] @@ -218,4 +223,34 @@ describe('parseKimiSessionFile', () => { const session = await parseKimiSessionFile(file, 'darwin') expect(session?.title).toBe('do the thing') }) + + it('caps cached session index maps by recent index path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-cache-')) + tempDirs.push(root) + const indexPaths: string[] = [] + + for (let index = 0; index <= KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const home = join(root, `home-${index}`) + await mkdir(home, { recursive: true }) + const indexPath = join(home, 'session_index.jsonl') + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: `session_${index}`, workDir: `/tmp/kimi-${index}` })}\n` + ) + indexPaths.push(indexPath) + } + + for (const indexPath of indexPaths.slice(0, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)) { + await readKimiWorkDirBySessionId(indexPath) + } + const refreshedFirst = await readKimiWorkDirBySessionId(indexPaths[0]) + expect(refreshedFirst.get('session_0')).toBe('/tmp/kimi-0') + await readKimiWorkDirBySessionId(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[0])).toBe(true) + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[1])).toBe(false) + expect( + hasKimiSessionIndexCacheEntryForTests(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + ).toBe(true) + }) }) diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index db307cb22d0..7ed457f6c07 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -4,6 +4,13 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import { asRecord, extractString } from './session-scanner-values' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS +} from './session-scanner-kimi-index-cache' + +export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -55,38 +62,41 @@ export function kimiPrimaryAgentWirePath( return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl') } -type WorkDirCacheEntry = { - mtimeMs: number - map: Promise> -} - // Why: every session under one Kimi home shares a single session_index.jsonl. -// Re-reading it once per session would be O(n^2); memoize by path + mtime so a -// scan reads the index at most once and the cache self-invalidates when Kimi -// appends a new session (mtime bump). -const workDirCacheByIndexPath = new Map() +// Re-reading it once per session would be O(n^2); memoize by path + file +// identity so a scan reads the index at most once. Bound and expire entries +// because host/WSL/runtime roots can change during one main-process lifetime. +const workDirCacheByIndexPath = new KimiSessionIndexCache() export function clearKimiSessionIndexCache(): void { workDirCacheByIndexPath.clear() } +export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolean { + return workDirCacheByIndexPath.has(indexPath) +} + export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { - let mtimeMs: number + const generation = workDirCacheByIndexPath.beginRead() + let identity: Awaited> try { - mtimeMs = (await stat(indexPath)).mtimeMs + identity = await stat(indexPath) } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. + workDirCacheByIndexPath.delete(indexPath, generation) return new Map() } - const cached = workDirCacheByIndexPath.get(indexPath) - if (cached && cached.mtimeMs === mtimeMs) { - return cached.map - } - - const map = parseKimiSessionIndex(indexPath) - workDirCacheByIndexPath.set(indexPath, { mtimeMs, map }) - return map + return workDirCacheByIndexPath.get( + indexPath, + { + changeTimeMs: identity.ctimeMs, + mtimeMs: identity.mtimeMs, + sizeBytes: identity.size + }, + generation, + () => parseKimiSessionIndex(indexPath) + ) } async function parseKimiSessionIndex(indexPath: string): Promise> { From 884781a5527f2b782b8df2a388dae9b22d132530 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:34:41 -0700 Subject: [PATCH 04/26] Contain smart-sort persistence failures on remote disconnect (#6994) --- .../src/components/sidebar/WorktreeList.tsx | 22 +---- .../worktree-sort-order-persistence.test.ts | 84 +++++++++++++++++++ .../lib/worktree-sort-order-persistence.ts | 35 ++++++++ 3 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/worktree-sort-order-persistence.test.ts create mode 100644 src/renderer/src/lib/worktree-sort-order-persistence.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 54806828e43..83f6b225e26 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -203,14 +203,12 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split' +import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, - type ExecutionHostId, - parseExecutionHostId + type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -5509,21 +5507,7 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: sortOrder is persisted in each host's worktreeMeta and enriched from // the owner host, so persist each host's ids on that host. const state = useAppStore.getState() - for (const group of splitWorktreeSortOrderByHost(state, sortedIds)) { - const parsed = parseExecutionHostId(group.hostId) - const target = - parsed?.kind === 'runtime' - ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) - : ({ kind: 'local' } as const) - void (target.kind === 'environment' - ? callRuntimeRpc( - target, - 'worktree.persistSortOrder', - { orderedIds: group.orderedIds }, - { timeoutMs: 15_000 } - ) - : window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds })) - } + persistWorktreeSortOrderByHost(state, sortedIds) }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.test.ts b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts new file mode 100644 index 00000000000..150194a572b --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { persistWorktreeSortOrderByHost } from './worktree-sort-order-persistence' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const localPersistSortOrder = vi.fn() +const runtimeCall = vi.mocked(callRuntimeRpc) +const UNHANDLED_REJECTION_SETTLE_MS = 20 + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +async function collectUnhandledRejections(run: () => void): Promise { + const reasons: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + reasons.push(reason) + } + + process.on('unhandledRejection', onUnhandledRejection) + try { + run() + await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + + return reasons +} + +beforeEach(() => { + runtimeCall.mockReset() + localPersistSortOrder.mockReset() + runtimeCall.mockResolvedValue(undefined) + localPersistSortOrder.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + worktrees: { + persistSortOrder: localPersistSortOrder + } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('persistWorktreeSortOrderByHost', () => { + it('persists each owner host through the matching transport', () => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + + expect(runtimeCall).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'worktree.persistSortOrder', + { orderedIds: ['runtime-repo::wt-b'] }, + { timeoutMs: 15_000 } + ) + expect(localPersistSortOrder).toHaveBeenCalledWith({ orderedIds: ['local-repo::wt-a'] }) + }) + + it('handles best-effort persistence rejections from disconnected hosts', async () => { + runtimeCall.mockRejectedValueOnce(new Error('SSH disconnected')) + localPersistSortOrder.mockRejectedValueOnce(new Error('local store unavailable')) + + const unhandledRejections = await collectUnhandledRejections(() => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + }) + + expect(unhandledRejections).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.ts b/src/renderer/src/lib/worktree-sort-order-persistence.ts new file mode 100644 index 00000000000..28e2d8d3556 --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.ts @@ -0,0 +1,35 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split' + +function ignoreSortOrderPersistenceFailure(promise: Promise): void { + void promise.catch(() => { + // Why: sort-order restore is best-effort; SSH disconnects during smart sort + // must not surface as unhandled rejections that pollute crash diagnostics. + }) +} + +export function persistWorktreeSortOrderByHost( + state: WorktreeRuntimeOwnerState, + orderedIds: readonly string[] +): void { + for (const group of splitWorktreeSortOrderByHost(state, orderedIds)) { + const parsed = parseExecutionHostId(group.hostId) + if (parsed?.kind === 'runtime') { + ignoreSortOrderPersistenceFailure( + callRuntimeRpc( + { kind: 'environment', environmentId: parsed.environmentId }, + 'worktree.persistSortOrder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + ) + continue + } + + ignoreSortOrderPersistenceFailure( + window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds }) + ) + } +} From b5cdab3a26014058a0d66c9f00e8c848d7616710 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:17 -0700 Subject: [PATCH 05/26] Fix tracked upstream generation cache leak (#7674) --- src/main/github/client.test.ts | 114 +++++++++++++++++++++++++++++++++ src/main/github/client.ts | 50 +++++++++++++-- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 5363cb56c60..c0a3c58be37 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -118,6 +118,7 @@ import { updatePRState, updatePRTitle, _getMergeQueueCacheSizeForTests, + _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests @@ -1786,6 +1787,119 @@ describe('getPRForBranch', () => { expect(trackedUpstreamCalls).toHaveLength(1) }) + it('releases tracked-upstream probe generations across runtime identities', async () => { + const sshGitProvider = { + exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + } + getSshGitProviderMock.mockReturnValue(sshGitProvider) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await getPRForBranch('/repo-root', 'feature', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 3, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) + }) + + it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + for (let index = 0; index < 513; index += 1) { + await getPRForBranch(`/repo-root-${index}`, 'feature') + } + await getPRForBranch('/repo-root-512', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 512, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) + + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root-fresh', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale probe cleanup from releasing a replacement generation', async () => { + let resolveOldProbe: (value: { stdout: string; stderr: string }) => void + let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldProbe = resolve + }) + ) + .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrentProbe = resolve + }) + ) + + const oldLookup = getPRForBranch('/repo-root', 'old') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) + __resetTrackedUpstreamBranchCacheForTests() + await getPRForBranch('/repo-root', 'replacement') + const currentLookup = getPRForBranch('/repo-root', 'current') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) + + resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) + await oldLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 1, + generations: 1 + }) + + resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) + await currentLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + }) + + it('releases tracked-upstream probe state when setup rejects', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) + + await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 0, + generations: 0 + }) + }) + it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e4005421a4..b4702f28128 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2392,6 +2392,7 @@ type TrackedUpstreamBranch = { } const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 +const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number @@ -2411,12 +2412,49 @@ const trackedUpstreamSnapshotInFlight = new Map< string, Promise >() -const trackedUpstreamSnapshotGenerations = new Map() +const trackedUpstreamSnapshotGenerations = new Map() -function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number { - const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1 - trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration) - return nextGeneration +function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { + const generation = Symbol() + trackedUpstreamSnapshotGenerations.set(cacheKey, generation) + return generation +} + +function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { + // Why: generations only guard an active probe; retaining completed repo keys + // leaks worktree/runtime identities after the short-lived snapshot TTL expires. + if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { + trackedUpstreamSnapshotGenerations.delete(cacheKey) + } +} + +function pruneTrackedUpstreamSnapshotCache(now: number): void { + for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { + if (cached.expiresAt <= now) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + } + // Why: workspace/runtime churn can create unbounded unique keys within one + // TTL window, so expiry sweeping alone is not a memory bound. + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldestKey = trackedUpstreamSnapshotCache.keys().next().value + if (oldestKey === undefined) { + break + } + trackedUpstreamSnapshotCache.delete(oldestKey) + } +} + +export function _getTrackedUpstreamBranchCacheSizesForTests(): { + snapshots: number + inFlight: number + generations: number +} { + return { + snapshots: trackedUpstreamSnapshotCache.size, + inFlight: trackedUpstreamSnapshotInFlight.size, + generations: trackedUpstreamSnapshotGenerations.size + } } export function __resetTrackedUpstreamBranchCacheForTests(): void { @@ -2510,6 +2548,7 @@ async function getTrackedUpstreamBranch( upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) + pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) @@ -2522,6 +2561,7 @@ async function getTrackedUpstreamBranch( if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { trackedUpstreamSnapshotInFlight.delete(cacheKey) } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) } } From 11e537616cdc56d8863bc9a779d5d10f1946e0b7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:31 -0700 Subject: [PATCH 06/26] Surface a friendly error when Windows blocks the user PATH write (#6412) (#6588) * Surface a friendly error when Windows blocks the user PATH write When HKCU\Environment is locked (Group Policy / restricted ACL / EDR-managed environment), the PowerShell SetEnvironmentVariable write rejects with a raw 'Command failed: powershell ...' error (often mojibake-localized) that the CLI Settings toast displayed verbatim. Detect the permission failure via the Latin PowerShell error-envelope tokens and rethrow an actionable message naming the exact folder to add to PATH manually, chaining the original error on cause. Fixes #6412 Co-authored-by: Orca * Tighten Windows PATH permission detection --------- Co-authored-by: Orca --- src/main/cli/cli-installer.test.ts | 82 ++++++++++++++++++++++++++++++ src/main/cli/cli-installer.ts | 56 ++++++++++++++++++-- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 4ce919cef6b..712659fa173 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -346,6 +346,88 @@ describe('CliInstaller', () => { expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) + it.each(['UnauthorizedAccessException', 'SecurityException'])( + 'rejects with a friendly message for Windows PATH denial: %s', + async (permissionMarker) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + // The .NET error id survives localized or mojibake PowerShell output. + const error = new Error( + `Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nFullyQualifiedErrorId : ${permissionMarker},Microsoft.PowerShell.Commands` + ) + Object.assign(error, { code: 1 }) + throw error + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(/access denied|Group Policy|manually/i) + await expect(result).rejects.not.toThrow(/Command failed: powershell/) + await expect(result).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(permissionMarker) + }) + }) + } + ) + + it('skips the Windows PATH write when removing an absent entry', async () => { + const fixture = await makeFixture() + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter + }) + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it.each([ + ['PowerShell timeout', 'Windows PATH command timed out after 5000ms.'], + [ + 'generic PowerShell method failure', + "Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nCategoryInfo : NotSpecified: (:) [], MethodInvocationException\nFullyQualifiedErrorId : MethodInvocationException" + ] + ])( + 'propagates a non-permission Windows PATH write error unchanged: %s', + async (_name, message) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + throw new Error(message) + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(message) + await expect(result).rejects.not.toThrow(/Windows blocked updating your user PATH/) + } + ) + it('settles when the Windows PATH query hangs', async () => { vi.useFakeTimers() const fixture = await makeFixture() diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6765d8249b1..0705c4aaee5 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -823,7 +823,7 @@ export class CliInstaller { return } entries.push(pathDirectory) - await this.userPathWriter(entries.join(';')) + await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add') } private async removeWindowsPathEntry(pathDirectory: string): Promise { @@ -831,10 +831,36 @@ export class CliInstaller { return } const current = await this.userPathReader() - const nextEntries = splitPathEntries('win32', current).filter( - (entry) => !samePathEntry('win32', entry, pathDirectory) - ) - await this.userPathWriter(nextEntries.join(';')) + const entries = splitPathEntries('win32', current) + const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + if (nextEntries.length === entries.length) { + return + } + await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') + } + + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes + // while preserving the original diagnostic as the error cause. + private async writeWindowsUserPathEntry( + value: string, + pathDirectory: string, + action: 'add' | 'remove' + ): Promise { + try { + await this.userPathWriter(value) + } catch (error) { + if (!isWindowsUserPathPermissionError(error)) { + throw error + } + const guidance = + action === 'add' + ? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + : `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + throw new Error( + `Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`, + { cause: error } + ) + } } } @@ -1038,6 +1064,26 @@ function isMissingError(error: unknown): boolean { ) } +// Why: localized permission errors retain these .NET/ACL markers even when +// their human-readable PowerShell text is mojibake. +function isWindowsUserPathPermissionError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const stderr = + 'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr + : '' + const haystack = `${error.message}\n${stderr}` + return ( + haystack.includes('UnauthorizedAccessException') || + haystack.includes('SecurityException') || + haystack.includes('Requested registry access is not allowed') || + haystack.includes('Access is denied') || + haystack.includes('Access to the registry key') + ) +} + function quoteShell(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` } From c3ccbdc30664c396de4b8e530d884ecb16e35c0e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:44:10 -0700 Subject: [PATCH 07/26] Fix worktree scan generation cache leak (#7675) --- src/main/git/worktree.test.ts | 130 +++++++++++++++++++++++++++++++++- src/main/git/worktree.ts | 39 ++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 54216ab9ad7..035a7f77405 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -23,6 +23,8 @@ import { clearGitCapabilityStateForTests } from './git-capability-state' import { addSparseWorktree, addWorktree, + _getWorktreeScanCacheSizesForTests, + _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees, moveWorktree, @@ -36,7 +38,13 @@ beforeEach(() => { }) describe('listWorktrees in-flight sharing', () => { - beforeEach(() => { + beforeEach(async () => { + gitExecFileAsyncMock.mockReset() + _resetWorktreeScanCacheForTests() + // Why: capability discovery serializes same-host callers; warming it lets + // these tests isolate the scan-generation overlap they are exercising. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await listWorktrees('/capability-warmup') gitExecFileAsyncMock.mockReset() }) @@ -87,6 +95,126 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('does not retain scan generations after mutations without active scans', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await Promise.all( + Array.from({ length: 128 }, (_, index) => + moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`) + ) + ) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('cleans mutation generations after stale scans settle and supports repo reuse', async () => { + const scanResolvers: ((stdout: string) => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push((stdout) => resolve({ stdout })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleScan = listWorktrees('/repo') + expect(scanResolvers).toHaveLength(1) + + await moveWorktree('/repo', '/repo-old', '/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + const freshScan = listWorktrees('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + + scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') + expect((await freshScan)[0]?.path).toBe('/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') + expect((await staleScan)[0]?.path).toBe('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + + const firstReuse = listWorktrees('/repo') + const secondReuse = listWorktrees('/repo') + expect(listCalls).toBe(3) + + scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n') + const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse]) + expect(firstResult).toEqual(secondResult) + expect(firstResult[0]?.path).toBe('/repo-reused') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('retires every overlapping scan generation across repeated mutations', async () => { + const scanResolvers: (() => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const oldestScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-0', '/new-0') + const middleScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-1', '/new-1') + const newestScan = listWorktrees('/repo') + + expect(listCalls).toBe(3) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + + scanResolvers[0]?.() + scanResolvers[2]?.() + scanResolvers[1]?.() + await Promise.all([oldestScan, middleScan, newestScan]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('keeps WSL scans distinct while retiring both after a mutation', async () => { + const scanResolvers: (() => void)[] = [] + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await Promise.all([ + listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }), + listWorktrees('/capability-warmup', { wslDistro: 'Debian' }) + ]) + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(2) + + await removeWorktree('/repo', '/worktree', false, { + knownRemovedWorktree: { branch: '', head: '' }, + wslDistro: 'Ubuntu' + }) + const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(4) + + for (const resolve of scanResolvers) { + resolve() + } + await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + it('does not share scans for callers with an AbortSignal', async () => { const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 37c017c04d5..c9175b3dcb4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -650,10 +650,48 @@ const inFlightWorktreeScans = new Map>() // settle for their original callers). const worktreeScanGenerations = new Map() +function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { + const keyPrefix = `${repoPath}\0` + for (const key of inFlightWorktreeScans.keys()) { + if (key.startsWith(keyPrefix)) { + return true + } + } + return false +} + function bumpWorktreeScanGeneration(repoPath: string): void { + // Why: generations only prevent joining a pre-mutation scan. Without an + // active scan, retaining the repo path just leaks completed mutation keys. + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + return + } worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) } +function pruneWorktreeScanGeneration(repoPath: string): void { + // Why: ordinary scan settlement should stay O(1); only repos invalidated + // during an active scan need the cross-generation in-flight check. + if (!worktreeScanGenerations.has(repoPath)) { + return + } + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + worktreeScanGenerations.delete(repoPath) + } +} + +export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } { + return { + inFlight: inFlightWorktreeScans.size, + generations: worktreeScanGenerations.size + } +} + +export function _resetWorktreeScanCacheForTests(): void { + inFlightWorktreeScans.clear() + worktreeScanGenerations.clear() +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -676,6 +714,7 @@ export function listWorktrees( if (inFlightWorktreeScans.get(key) === scan) { inFlightWorktreeScans.delete(key) } + pruneWorktreeScanGeneration(repoPath) }) inFlightWorktreeScans.set(key, scan) return scan From 19102355d78e65719650a0d2f7177ccb93847a64 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:45:50 -0700 Subject: [PATCH 08/26] perf(renderer): keep worktree activity summaries ref-stable across unrelated churn (#7626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectWorktreeAgentActivitySummary's shared cache keys on agentStatusEpoch, so any epoch bump — an UNRELATED worktree's agent transition, or a freshness tick — invalidated it and rebuilt every worktree's summary with fresh objects/Sets. Each sidebar card subscribes to its own summary via useShallow, and the new nested agentStatusPaneIdsByTabId ref defeated the shallow compare, re-rendering every card with agents on every transition anywhere. Reuse the prior summary object when structurally equal (booleans + Set-membership), mirroring reuseArrayIfEqual, so only the transitioning card re-renders. Co-authored-by: Orca --- .../worktree-agent-activity-summary.test.ts | 114 ++++++++++++++++++ .../worktree-agent-activity-summary.ts | 54 +++++++++ 2 files changed, 168 insertions(+) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 6b03422890c..26c5d2828df 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' @@ -164,6 +165,119 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('limits summary-reference churn to the transitioning worktree at scale', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const worktreeIds = Array.from({ length: 12 }, (_, index) => `repo::/wt-${index}`) + const tabsByWorktree = Object.fromEntries( + worktreeIds.map((worktreeId, index) => [worktreeId, [makeTab(`tab-${index}`, worktreeId)]]) + ) + const initialStatuses = Object.fromEntries( + worktreeIds.map((_, index) => { + const paneKey = makePaneKey(`tab-${index}`, LEAF_ID) + return [paneKey, makeAgentStatusEntry({ paneKey, state: 'working' })] + }) + ) + const changedPaneKey = makePaneKey('tab-11', LEAF_ID) + const baseInputs = { + tabsByWorktree, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + const state: AgentActivityInput = { + ...baseInputs, + agentStatusEpoch: 0, + agentStatusByPaneKey: initialStatuses + } + const changedState: AgentActivityInput = { + ...baseInputs, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + ...initialStatuses, + [changedPaneKey]: makeAgentStatusEntry({ paneKey: changedPaneKey, state: 'done' }) + } + } + + const before = worktreeIds.map((worktreeId) => + selectWorktreeAgentActivitySummary(state, worktreeId) + ) + const after = worktreeIds.map((worktreeId) => + selectWorktreeAgentActivitySummary(changedState, worktreeId) + ) + const changedReferenceCount = after.filter((summary, index) => summary !== before[index]).length + const shallowNotificationCount = after.filter( + (summary, index) => !shallow(summary, before[index]) + ).length + + // Why: shallow store subscriptions wake on the nested pane-id map reference. + // One transition must not schedule downstream work for every other card. + expect(changedReferenceCount).toBe(1) + expect(shallowNotificationCount).toBe(1) + expect(after[11]).toMatchObject({ hasLiveWorking: false, hasLiveDone: true }) + }) + + it('reuses only summaries whose pane membership is still current', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const worktreeId = 'repo::/wt-1' + const firstPaneKey = makePaneKey('tab-1', LEAF_ID) + const secondPaneKey = makePaneKey('tab-2', LEAF_ID) + const replacementPaneKey = makePaneKey('tab-3', LEAF_ID) + const sharedInputs = { + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + const initial: AgentActivityInput = { + ...sharedInputs, + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId), makeTab('tab-2', worktreeId)] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [firstPaneKey]: makeAgentStatusEntry({ paneKey: firstPaneKey, state: 'working' }), + [secondPaneKey]: makeAgentStatusEntry({ paneKey: secondPaneKey, state: 'working' }) + } + } + const reordered: AgentActivityInput = { + ...initial, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [secondPaneKey]: initial.agentStatusByPaneKey[secondPaneKey], + [firstPaneKey]: initial.agentStatusByPaneKey[firstPaneKey] + } + } + const replacement: AgentActivityInput = { + ...sharedInputs, + tabsByWorktree: { [worktreeId]: [makeTab('tab-3', worktreeId)] }, + agentStatusEpoch: 2, + agentStatusByPaneKey: { + [replacementPaneKey]: makeAgentStatusEntry({ + paneKey: replacementPaneKey, + state: 'working' + }) + } + } + const removed: AgentActivityInput = { + ...replacement, + agentStatusEpoch: 3, + agentStatusByPaneKey: {} + } + + const first = selectWorktreeAgentActivitySummary(initial, worktreeId) + const afterReorder = selectWorktreeAgentActivitySummary(reordered, worktreeId) + const afterReplacement = selectWorktreeAgentActivitySummary(replacement, worktreeId) + const afterRemoval = selectWorktreeAgentActivitySummary(removed, worktreeId) + + expect(afterReorder).toBe(first) + expect(afterReplacement).not.toBe(first) + expect(afterReplacement.agentStatusPaneIdsByTabId).toEqual({ + 'tab-3': new Set([LEAF_ID]) + }) + expect(afterRemoval).not.toBe(afterReplacement) + expect(afterRemoval).toMatchObject({ hasLiveWorking: false }) + expect(afterRemoval.agentStatusPaneIdsByTabId).toEqual({}) + }) + it('summarizes worktree-attributed rows missing from the tab list', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const childPaneKey = makePaneKey('tab-child', '22222222-2222-4222-8222-222222222222') diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index ac44c11b717..6802a3603cf 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -139,6 +139,18 @@ function getWorktreeAgentActivitySummaries( addParentPaneId(summary, orchestration, retained.worktreeId, tabIdToWorktreeId) } + // Why: epoch changes rebuild every summary, so reuse structurally equal results + // to keep unrelated worktree subscriptions from scheduling card renders. + const previousSummaries = agentActivityCache?.summaries + if (previousSummaries) { + for (const [worktreeId, summary] of summaries) { + const previous = previousSummaries.get(worktreeId) + if (previous && summariesEqual(previous, summary)) { + summaries.set(worktreeId, previous) + } + } + } + agentActivityCache = { tabsByWorktree: state.tabsByWorktree, agentStatusEpoch: state.agentStatusEpoch, @@ -150,6 +162,48 @@ function getWorktreeAgentActivitySummaries( return summaries } +function summariesEqual( + previous: WorktreeAgentActivitySummary, + next: WorktreeAgentActivitySummary +): boolean { + return ( + previous.hasPermission === next.hasPermission && + previous.hasLiveWorking === next.hasLiveWorking && + previous.hasLiveDone === next.hasLiveDone && + previous.hasRetainedDone === next.hasRetainedDone && + agentStatusPaneIdsByTabIdEqual( + previous.agentStatusPaneIdsByTabId, + next.agentStatusPaneIdsByTabId + ) + ) +} + +function agentStatusPaneIdsByTabIdEqual( + previous: Record>, + next: Record> +): boolean { + if (previous === next) { + return true + } + const previousKeys = Object.keys(previous) + if (previousKeys.length !== Object.keys(next).length) { + return false + } + for (const tabId of previousKeys) { + const previousPaneIds = previous[tabId] + const nextPaneIds = next[tabId] + if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) { + return false + } + for (const paneId of previousPaneIds) { + if (!nextPaneIds.has(paneId)) { + return false + } + } + } + return true +} + function resolveEntryOrchestration( entry: Pick, runtimeOrchestration: AgentStatusOrchestrationContext | undefined From 6d3ab9f971fc8a84286ed94e44ff69862dd739eb Mon Sep 17 00:00:00 2001 From: Marius Date: Sat, 11 Jul 2026 22:45:52 +0200 Subject: [PATCH 09/26] feat(status-bar): choose used or remaining usage (#7574) Add a persisted Appearance setting that switches provider usage labels between percent used and percent remaining while keeping meter fill consumption-based. Cover desktop and web persistence, current providers, settings search, localized copy, and regression tests. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> --- src/main/persistence.ts | 7 ++ src/main/runtime/rpc/methods/client-ui.ts | 1 + .../settings/AppearancePane.test.tsx | 20 +++++ .../AppearanceWindowSidebarSection.tsx | 60 +++++++++++++-- .../components/settings/appearance-search.ts | 10 ++- .../appearance-usage-percentage-search.ts | 18 +++++ .../src/components/status-bar/StatusBar.tsx | 76 ++++++++++++------- .../status-bar/inline-usage-bars.test.tsx | 37 ++++++++- .../status-bar-provider-menu-focus.test.tsx | 14 +++- .../src/components/status-bar/tooltip.test.ts | 32 ++++++++ .../src/components/status-bar/tooltip.tsx | 15 ++-- .../status-bar/usage-percentage-label.test.ts | 15 ++++ .../status-bar/usage-percentage-label.ts | 19 +++++ 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 ++- src/renderer/src/store/slices/ui.test.ts | 26 +++++++ src/renderer/src/store/slices/ui.ts | 14 ++++ src/renderer/src/web/web-preload-api.ts | 4 + src/shared/constants.ts | 2 + src/shared/types.ts | 3 + src/shared/usage-percentage-display.test.ts | 25 ++++++ src/shared/usage-percentage-display.ts | 21 +++++ 25 files changed, 423 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-usage-percentage-search.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.ts create mode 100644 src/shared/usage-percentage-display.test.ts create mode 100644 src/shared/usage-percentage-display.ts diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0cac89b3f17..19cce1bbacb 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -104,6 +104,7 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -5333,6 +5334,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + this.state.ui?.usagePercentageDisplay + ), // Why: strict boolean coercion so a missing/legacy value reads as false // (first-run notice still fires) rather than leaking a non-bool through. trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, @@ -5412,6 +5416,9 @@ export class Store { sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..271fcefad75 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -208,6 +208,7 @@ const UiUpdate = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 127df935ff7..dc5a4abd1aa 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ settingsSearchQuery: 'automations', statusBarItems: [], toggleStatusBarItem: vi.fn(), + usagePercentageDisplay: 'used' as 'used' | 'remaining', + setUsagePercentageDisplay: vi.fn(), recordFeatureInteraction: vi.fn(), setWorktreeCardMode: vi.fn() } @@ -176,6 +178,7 @@ describe('AppearancePane', () => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] mocks.state.settingsSearchQuery = 'automations' + mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. @@ -387,6 +390,23 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('updates the usage percentage display from the latest status bar settings section', async () => { + mocks.state.settingsSearchQuery = 'remaining' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const remainingButton = Array.from( + container.querySelectorAll('button[role="radio"]') + ).find((button) => button.textContent === 'Remaining') + + expect(container.textContent).toContain('Usage percentages') + expect(remainingButton).toBeDefined() + + await act(async () => { + remainingButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.setUsagePercentageDisplay).toHaveBeenCalledWith('remaining') + }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { mocks.state.availableStatusBarToggles = [ { diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1690832ee18..93b9f94535e 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -12,7 +12,12 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' -import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search' +import { + getLayoutEntries, + getSidebarEntries, + getStatusBarToggles, + getUsagePercentageDisplayEntry +} from './appearance-search' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, @@ -60,9 +65,12 @@ export function AppearanceWindowSidebarSection({ const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const statusBarItems = useAppStore((state) => state.statusBarItems) const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem) + const usagePercentageDisplay = useAppStore((state) => state.usagePercentageDisplay) + const setUsagePercentageDisplay = useAppStore((state) => state.setUsagePercentageDisplay) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode) const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles()) + const usagePercentageDisplayEntry = getUsagePercentageDisplayEntry() const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry() const sidebarEntries = getSidebarEntries() const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry() @@ -81,13 +89,15 @@ export function AppearanceWindowSidebarSection({ description: statusBarDescription, keywords: statusBarKeywords }) - const statusBarControlMatches = visibleStatusBarToggles.some((toggle) => - matchesSettingsSearch(searchQuery, { - title: toggle.title, - description: toggle.description, - keywords: toggle.keywords - }) - ) + const statusBarControlMatches = + matchesSettingsSearch(searchQuery, usagePercentageDisplayEntry) || + visibleStatusBarToggles.some((toggle) => + matchesSettingsSearch(searchQuery, { + title: toggle.title, + description: toggle.description, + keywords: toggle.keywords + }) + ) const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [ workspaceCardLayoutEntry, ...sidebarEntries @@ -119,6 +129,40 @@ export function AppearanceWindowSidebarSection({ {showStatusBarControls ? (
+ + + } + /> + + {visibleStatusBarToggles.map((toggle) => { const enabled = statusBarItems.includes(toggle.id) return ( diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 7284b5db315..3ceaf412d13 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -8,8 +8,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' +import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' -export { getStatusBarToggles } +export { getStatusBarToggles, getUsagePercentageDisplayEntry } export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -148,13 +149,14 @@ export const getTitlebarEntries = createLocalizedCatalog((): SettingsSearchEntry } ]) -export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => - getStatusBarToggles().map(({ title, description, keywords }) => ({ +export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ + getUsagePercentageDisplayEntry(), + ...getStatusBarToggles().map(({ title, description, keywords }) => ({ title, description, keywords })) -) +]) export { getLeftSidebarAppearanceEntry, getSidebarEntries } diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts new file mode 100644 index 00000000000..9375c7c9fd1 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -0,0 +1,18 @@ +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({ + title: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayTitle', + 'Usage percentages' + ), + description: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayDescription', + 'Choose whether provider limits show the percentage used or remaining.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.896eb53fd4', 'status bar') + ] +})) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index aa826573e73..281c968fa2f 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -79,6 +79,11 @@ import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' +import { + normalizeUsagePercentageDisplay, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' type StatusBarProps = { floatingTerminalOpen: boolean @@ -963,9 +968,11 @@ export function InlineUsageBars({ limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - // Why: show % used (consumption), not remaining — matches harness meters (#7551). - // Keep the "used" word in compact labels so bare "32% 5h" is not read as remaining. - const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used') + const display = normalizeUsagePercentageDisplay( + useAppStore((state) => state.usagePercentageDisplay) + ) + // Why: the preference changes copy, while bar fill stays consumption-based + // so empty/green and full/red keep the meter semantics introduced in #8167. const usageWindows = [ limits.session ? { @@ -1006,8 +1013,7 @@ export function InlineUsageBars({ />
- {window.used} - {usedSuffix} {window.label} + {formatUsagePercentageLabel(window.used, display)} {window.label} ))} @@ -1080,16 +1086,21 @@ function InlineUsageSkeleton(): React.JSX.Element { } // --------------------------------------------------------------------------- -// Window label (shows percent used / consumption) +// Window label // --------------------------------------------------------------------------- -function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element { - const used = clampUsedPercent(w.usedPercent) - // Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit. +function WindowLabel({ + w, + label, + display +}: { + w: RateLimitWindow + label: string + display: UsagePercentageDisplay +}): React.JSX.Element { return ( - {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label} + {formatUsagePercentageLabel(w.usedPercent, display)} {label} ) } @@ -1104,10 +1115,12 @@ const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) function ProviderSegment({ p, - compact + compact, + display }: { p: ProviderRateLimits | null compact: boolean + display: UsagePercentageDisplay }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' @@ -1160,20 +1173,20 @@ function ProviderSegment({ return ( - {visibleBuckets.map((bucket, i) => { - const used = clampUsedPercent(bucket.usedPercent) - return ( - - {i > 0 && ·} - - {bucket.name} {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - - - ) - })} + {visibleBuckets.map((bucket, i) => ( + + {i > 0 && ·} + + {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} + + + ))} {visibleBuckets.length === 0 && p.session && ( - + )} {isStale && } @@ -1211,7 +1224,7 @@ function ProviderSegment({ {visibleWindows.map((window, index) => ( {index > 0 && ·} - + ))} {isStale && } @@ -1745,6 +1758,9 @@ export function ProviderDetailsMenu({ children?: React.ReactNode }): React.JSX.Element { const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const usagePercentageDisplay = normalizeUsagePercentageDisplay( + useAppStore((s) => s.usagePercentageDisplay) + ) const skipCloseAutoFocusRef = useRef(false) const handleOpenChange = (nextOpen: boolean): void => { @@ -1787,7 +1803,7 @@ export function ProviderDetailsMenu({ ) : ( - + )} @@ -1812,7 +1828,11 @@ export function ProviderDetailsMenu({ {topContent}
{/* Why: provider-specific action sections may render richer reset-credit UI. */} - +
{children ? ( <> diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index 018d260768f..404caa344dc 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -1,16 +1,31 @@ import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' vi.mock('@/i18n/i18n', () => ({ i18n: { language: 'en' }, - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +const mocks = vi.hoisted(() => ({ + usagePercentageDisplay: 'used' as 'used' | 'remaining' +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: mocks.usagePercentageDisplay }) +})) + function claudeLimits(): ProviderRateLimits { return { provider: 'claude', @@ -39,6 +54,10 @@ function claudeLimits(): ProviderRateLimits { } describe('InlineUsageBars', () => { + beforeEach(() => { + mocks.usagePercentageDisplay = 'used' + }) + it('renders Claude Fable usage in inactive account preview rows', async () => { const { InlineUsageBars } = await import('./StatusBar') @@ -51,4 +70,18 @@ describe('InlineUsageBars', () => { expect(markup).toContain('16% used wk') expect(markup).toContain('42% used Fable') }) + + it('shows remaining copy without reversing consumption meter fill', async () => { + mocks.usagePercentageDisplay = 'remaining' + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% left 5h') + expect(markup).toContain('84% left wk') + expect(markup).toContain('58% left Fable') + expect(markup).toContain('width:32%') + }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx index 25dfa651bc5..a72761322a5 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' const recordFeatureInteractionMock = vi.fn() +const usagePercentageDisplayMock = 'used' vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() @@ -17,8 +18,15 @@ vi.mock('react', async () => { vi.mock('../../store', () => ({ useAppStore: ( - selector: (state: { recordFeatureInteraction: typeof recordFeatureInteractionMock }) => unknown - ) => selector({ recordFeatureInteraction: recordFeatureInteractionMock }) + selector: (state: { + recordFeatureInteraction: typeof recordFeatureInteractionMock + usagePercentageDisplay: 'used' + }) => unknown + ) => + selector({ + recordFeatureInteraction: recordFeatureInteractionMock, + usagePercentageDisplay: usagePercentageDisplayMock + }) })) vi.mock('./tooltip', () => ({ @@ -108,8 +116,10 @@ describe('ProviderDetailsMenu focus handoff', () => { const element = await renderProviderDetailsMenu() const dropdown = findChildByType(element, 'DropdownMenu') const content = findChildByType(element, 'DropdownMenuContent') + const providerPanel = findChildByType(element, 'ProviderPanel') expect(dropdown.props.modal).toBe(false) + expect(providerPanel.props.usagePercentageDisplay).toBe('used') const preventDefault = vi.fn() ;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 8f214ecc7fa..c553f68ddb1 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -45,6 +45,17 @@ function provider(overrides: Partial = {}): ProviderRateLimi } } +const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok' +] + afterEach(() => { vi.useRealTimers() }) @@ -473,6 +484,27 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('width:100%') expect(markup).not.toContain('140%') }) + + it.each(PROVIDER_IDS)( + 'applies remaining copy to %s while retaining consumption bar direction', + (providerId) => { + const p = provider({ + provider: providerId, + status: 'ok', + session: { + usedPercent: 25, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + + expect(markup).toContain('75% left') + expect(markup).toContain('width:25%') + } + ) }) describe('clampUsedPercent', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 47ba6414267..7890529bde2 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -7,6 +7,8 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' export { getProviderDisplayName, @@ -213,12 +215,14 @@ export function ProviderPanel({ p, inverted = false, className, - showResetCredits = true + showResetCredits = true, + usagePercentageDisplay = 'used' }: { p: ProviderRateLimits | null inverted?: boolean className?: string showResetCredits?: boolean + usagePercentageDisplay?: UsagePercentageDisplay }): React.JSX.Element { const textClass = inverted ? 'text-background' : 'text-foreground' const mutedClass = inverted ? 'text-background/60' : 'text-muted-foreground' @@ -288,8 +292,8 @@ export function ProviderPanel({ if (!w) { return null } - // Why: show % used (consumption), not remaining — matches Claude/Codex - // harness meters and avoids the "full green bar = exhausted" misread (#7551). + // Why: preference changes the copy only; consumption-based bar direction + // preserves the empty/green to full/red meter convention from #8167. const usedPct = clampUsedPercent(w.usedPercent) const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null @@ -303,10 +307,7 @@ export function ProviderPanel({ />
- - {usedPct} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - + {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} {resetLabel && {resetLabel}}
diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.test.ts b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts new file mode 100644 index 00000000000..e994bf6b034 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +import { formatUsagePercentageLabel } from './usage-percentage-label' + +describe('formatUsagePercentageLabel', () => { + it('formats used and remaining percentages without changing source semantics', () => { + expect(formatUsagePercentageLabel(8, 'used')).toBe('8% used') + expect(formatUsagePercentageLabel(8, 'remaining')).toBe('92% left') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.ts b/src/renderer/src/components/status-bar/usage-percentage-label.ts new file mode 100644 index 00000000000..395c6831bf4 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.ts @@ -0,0 +1,19 @@ +import { translate } from '@/i18n/i18n' +import { + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' + +export function formatUsagePercentageLabel( + usedPercent: number, + display: UsagePercentageDisplay +): string { + const percentage = getDisplayedUsagePercentage(usedPercent, display) + return display === 'used' + ? translate('auto.components.status.bar.usagePercentageLabel.used', '{{value0}}% used', { + value0: String(percentage) + }) + : translate('auto.components.status.bar.usagePercentageLabel.remaining', '{{value0}}% left', { + value0: String(percentage) + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d9e2d1e3bb8..c9c058e3276 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH Host" + }, + "usagePercentageLabel": { + "used": "{{value0}}% used", + "remaining": "{{value0}}% left" } } }, @@ -7238,7 +7242,9 @@ "f8e2a1c4b6": "Grok Usage", "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Usage percentages", + "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Weekly credits", "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Used", + "usagePercentageDisplayRemaining": "Remaining" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e30c7b66f9f..c84e4f354d9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "Host SSH" + }, + "usagePercentageLabel": { + "used": "{{value0}}% usado", + "remaining": "{{value0}}% restante" } } }, @@ -7201,7 +7205,9 @@ "f8e2a1c4b6": "Uso de Grok", "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Porcentajes de uso", + "usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Créditos semanales", "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Usado", + "usagePercentageDisplayRemaining": "Restante" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1651fbb09d2..6d481080c55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 使用済み", + "remaining": "残り {{value0}}%" } } }, @@ -7223,7 +7227,9 @@ "f8e2a1c4b6": "Grok の使用状況", "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "使用率", + "usagePercentageDisplayDescription": "プロバイダーの制限を使用済みまたは残りの割合で表示するかを選択します。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "週次クレジット", "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "使用済み", + "usagePercentageDisplayRemaining": "残り" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f5af8bbdb59..8e5fc8a4c5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 사용", + "remaining": "{{value0}}% 남음" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 사용량", "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "사용량 백분율", + "usagePercentageDisplayDescription": "공급자 한도를 사용한 비율 또는 남은 비율로 표시할지 선택합니다." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "주간 크레딧", "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "사용", + "usagePercentageDisplayRemaining": "남음" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c4eb2fdbe8f..d4e2f36f918 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 主机" + }, + "usagePercentageLabel": { + "used": "已用 {{value0}}%", + "remaining": "剩余 {{value0}}%" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 使用量", "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "用量百分比", + "usagePercentageDisplayDescription": "选择以已用或剩余百分比显示提供商限额。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "每周额度", "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "已用", + "usagePercentageDisplayRemaining": "剩余" } }, "right": { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 0f88ef4ca52..9e2e82e0cb4 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1260,6 +1260,32 @@ describe('createUISlice hydratePersistedUI', () => { expect(setUI).not.toHaveBeenCalled() }) + it('persists and hydrates the usage percentage display preference', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setUsagePercentageDisplay('used') + + expect(store.getState().usagePercentageDisplay).toBe('used') + expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' }) + + store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' })) + expect(store.getState().usagePercentageDisplay).toBe('remaining') + }) + + it('defaults invalid usage percentage display values to used', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + usagePercentageDisplay: 'left' as PersistedUIState['usagePercentageDisplay'] + }) + ) + + expect(store.getState().usagePercentageDisplay).toBe('used') + }) + it('clamps persisted workspace board column width', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 205d31878cc..a0f6aa0db73 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -29,6 +29,11 @@ import type { WorkspaceHostScope, VisibleWorkspaceHostIds } from '../../../../shared/types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + DEFAULT_USAGE_PERCENTAGE_DISPLAY, + normalizeUsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../shared/task-source-context' @@ -869,6 +874,8 @@ export type UISlice = { toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void + usagePercentageDisplay: UsagePercentageDisplay + setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null workspacePortScansByKey: Record workspacePortScanRefreshing: boolean @@ -2112,6 +2119,12 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ statusBarVisible: v }).catch(console.error) set({ statusBarVisible: v }) }, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + setUsagePercentageDisplay: (display) => { + const normalized = normalizeUsagePercentageDisplay(display) + window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error) + set({ usagePercentageDisplay: normalized }) + }, workspacePortScan: null, workspacePortScansByKey: {}, workspacePortScanRefreshing: false, @@ -2400,6 +2413,7 @@ export const createUISlice: StateCreator = (set, get) syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet // dismissal persists a `false` value. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index cd77c51acef..4e4b5502051 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -71,6 +71,7 @@ import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { @@ -3251,6 +3252,9 @@ function mergeWebUIState( safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, agentActivityDisplayMode: normalizeAgentActivityDisplayMode( safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay ) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f0b6c9dc04e..1a8a3f6ffe6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -31,6 +31,7 @@ import { import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -495,6 +496,7 @@ export function getDefaultUIState(): PersistedUIState { _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, dismissedUpdateVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 24308e9327f..fedd1623e46 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,7 @@ import type { GlobalWindowsRuntimeDefault, LocalWindowsRuntimePreference } from './project-execution-runtime' +import type { UsagePercentageDisplay } from './usage-percentage-display' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3311,6 +3312,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean + /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ + usagePercentageDisplay?: UsagePercentageDisplay dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null pendingUpdateNudgeId?: string | null diff --git a/src/shared/usage-percentage-display.test.ts b/src/shared/usage-percentage-display.test.ts new file mode 100644 index 00000000000..feae8c4371d --- /dev/null +++ b/src/shared/usage-percentage-display.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + getDisplayedUsagePercentage, + normalizeUsagePercentageDisplay +} from './usage-percentage-display' + +describe('usage percentage display', () => { + it('defaults unknown persisted values to the current used-capacity behavior', () => { + expect(normalizeUsagePercentageDisplay(undefined)).toBe('used') + expect(normalizeUsagePercentageDisplay('left')).toBe('used') + }) + + it('shows either the provider value or its complement', () => { + expect(getDisplayedUsagePercentage(6, 'used')).toBe(6) + expect(getDisplayedUsagePercentage(6, 'remaining')).toBe(94) + }) + + it('rounds and bounds percentages for display', () => { + expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21) + expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80) + expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0) + expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0) + expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0) + }) +}) diff --git a/src/shared/usage-percentage-display.ts b/src/shared/usage-percentage-display.ts new file mode 100644 index 00000000000..ad2603df820 --- /dev/null +++ b/src/shared/usage-percentage-display.ts @@ -0,0 +1,21 @@ +export type UsagePercentageDisplay = 'used' | 'remaining' + +// Why: missing settings preserve the consumption-meter behavior introduced in #8167. +export const DEFAULT_USAGE_PERCENTAGE_DISPLAY: UsagePercentageDisplay = 'used' + +export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentageDisplay { + return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY +} + +export function getDisplayedUsagePercentage( + usedPercent: number, + display: UsagePercentageDisplay +): number { + if (!Number.isFinite(usedPercent)) { + // Why: invalid provider data must not be presented as 100% remaining capacity. + return 0 + } + const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent)) + const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent + return Math.round(percentage) +} From a92fd26c96a9f5c14122dc14c14c683135138e2a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:46:11 -0700 Subject: [PATCH 10/26] perf(renderer): drive the agent-completion feedback observer off the agent-status epoch (#7621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-mounted observer that watches for successful agent completions subscribed to the whole agentStatusByPaneKey map, which is re-spread to a new object on every agent status ping (including high-frequency still-working pings). That re-rendered the observer and re-ran the app-wide done-transition scan on every ping. Subscribe to agentStatusEpoch instead — it bumps on exactly the state transitions the observer detects — and read the map imperatively via getState(), mirroring useActivityUnreadCount. Behavior is preserved; a Profiler render-count test guards that non-epoch churn no longer re-renders. Co-authored-by: Orca --- .../StarNagAgentValueMomentObserver.test.tsx | 69 ++++++++++++++++++- .../StarNagAgentValueMomentObserver.tsx | 36 +++++----- .../agent-status-worktree-purge-leak.test.ts | 2 + src/renderer/src/store/slices/worktrees.ts | 6 +- 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx index 4e1f7f5f4a0..3a689eac09a 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx @@ -1,12 +1,14 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { act, Profiler } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '@/store' import { StarNagAgentValueMomentObserver } from './StarNagAgentValueMomentObserver' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + type StarNagApi = { agentValueMoment: ReturnType showAgentValueMoment: ReturnType @@ -38,6 +40,25 @@ function renderObserver(): { root: Root; container: HTMLDivElement } { return { root, container } } +function renderObserverWithProfiler(): { + root: Root + container: HTMLDivElement + getRenderCount: () => number +} { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + let renderCount = 0 + act(() => { + root.render( + (renderCount += 1)}> + + + ) + }) + return { root, container, getRenderCount: () => renderCount } +} + function setAgentEntries(entries: Record): void { act(() => { useAppStore.setState((state) => ({ @@ -97,6 +118,52 @@ describe('StarNagAgentValueMomentObserver', () => { expect(showAgentValueMoment).toHaveBeenCalledTimes(1) }) + it('does not scan 500 agent statuses on 50 same-state working pings', () => { + const entries = Object.fromEntries( + Array.from({ length: 500 }, (_unused, index) => [ + `pane-${index}`, + entry({ paneKey: `tab-${index}:leaf-1` }) + ]) + ) + useAppStore.setState({ agentStatusByPaneKey: entries, agentStatusEpoch: 1 }) + const rendered = renderObserverWithProfiler() + root = rendered.root + container = rendered.container + const initialRenders = rendered.getRenderCount() + + // Why: separate acts model separate hook pings; main produced 50 renders and + // 25,000 completion-scan entry visits for this exact 500 x 50 scenario. + for (let ping = 0; ping < 50; ping += 1) { + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + 'pane-0': entry({ paneKey: 'tab-0:leaf-1', updatedAt: ping + 2 }) + } + })) + }) + } + expect(rendered.getRenderCount()).toBe(initialRenders) + + // A real transition bumps the epoch and must re-render. + setAgentEntries({ pane: entry({ state: 'done', updatedAt: 100 }) }) + expect(rendered.getRenderCount()).toBeGreaterThan(initialRenders) + }) + + it('does not treat a removed and reused pane key as one completion transition', () => { + ;({ root, container } = renderObserver()) + + setAgentEntries({ pane: entry({ state: 'working' }) }) + setAgentEntries({}) + setAgentEntries({ pane: entry({ state: 'done', updatedAt: 2 }) }) + act(() => { + vi.advanceTimersByTime(2400) + }) + + expect(agentValueMoment).not.toHaveBeenCalled() + expect(showAgentValueMoment).not.toHaveBeenCalled() + }) + it('ignores interrupted or empty-prompt completions', () => { ;({ root, container } = renderObserver()) diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 3dcd4b46c64..1fd3099323d 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from 'react' -import { useShallow } from 'zustand/react/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '@/store' @@ -48,14 +47,14 @@ function isTypingKeyEvent(event: KeyboardEvent): boolean { } export function StarNagAgentValueMomentObserver(): null { - const { agentStatusByPaneKey, agentStatusEpoch } = useAppStore( - useShallow((state) => ({ - agentStatusByPaneKey: state.agentStatusByPaneKey, - agentStatusEpoch: state.agentStatusEpoch - })) - ) + // Why: agentStatusByPaneKey is re-spread to a new object on every status ping + // (including high-frequency still-working pings that never change what we + // detect), so subscribing to the map re-rendered this always-mounted observer + // — and re-ran the app-wide done-transition scan — on every ping. agentStatusEpoch + // bumps on exactly the state transitions we care about, so drive off the epoch + // and read the map imperatively via getState(). + const agentStatusEpoch = useAppStore((state) => state.agentStatusEpoch) const previousEntriesRef = useRef(null) - const latestEntriesRef = useRef(agentStatusByPaneKey) const pendingRef = useRef(false) const requestedRef = useRef(false) const preparationRef = useRef(null) @@ -72,7 +71,10 @@ export function StarNagAgentValueMomentObserver(): null { return } const elapsedSinceTyping = Date.now() - lastTypingAtRef.current - if (hasActiveAgent(latestEntriesRef.current) || elapsedSinceTyping < QUIET_WINDOW_MS) { + if ( + hasActiveAgent(useAppStore.getState().agentStatusByPaneKey) || + elapsedSinceTyping < QUIET_WINDOW_MS + ) { scheduleCheck() return } @@ -86,7 +88,10 @@ export function StarNagAgentValueMomentObserver(): null { } } const freshElapsedSinceTyping = Date.now() - lastTypingAtRef.current - if (hasActiveAgent(latestEntriesRef.current) || freshElapsedSinceTyping < QUIET_WINDOW_MS) { + if ( + hasActiveAgent(useAppStore.getState().agentStatusByPaneKey) || + freshElapsedSinceTyping < QUIET_WINDOW_MS + ) { scheduleCheck() return } @@ -97,10 +102,6 @@ export function StarNagAgentValueMomentObserver(): null { }, CHECK_DELAY_MS) }, []) - useEffect(() => { - latestEntriesRef.current = agentStatusByPaneKey - }, [agentStatusByPaneKey, agentStatusEpoch]) - useEffect(() => { const markTyping = (event: Event): void => { if (event instanceof KeyboardEvent) { @@ -119,17 +120,18 @@ export function StarNagAgentValueMomentObserver(): null { }, []) useEffect(() => { + const currentEntries = useAppStore.getState().agentStatusByPaneKey const previousEntries = previousEntriesRef.current - previousEntriesRef.current = agentStatusByPaneKey + previousEntriesRef.current = currentEntries if (!previousEntries || requestedRef.current) { return } - if (!hasSuccessfulDoneTransition(previousEntries, agentStatusByPaneKey)) { + if (!hasSuccessfulDoneTransition(previousEntries, currentEntries)) { return } pendingRef.current = true scheduleCheck() - }, [agentStatusByPaneKey, agentStatusEpoch, scheduleCheck]) + }, [agentStatusEpoch, scheduleCheck]) useEffect(() => { return () => { diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts index 681c26f81c4..9052399a4e6 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts @@ -92,11 +92,13 @@ describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak r it('purgeWorktreeTerminalState drops every pane-scoped map for the removed worktree', () => { const store = createTestStore() seedPaneState(store) + const epochBefore = store.getState().agentStatusEpoch store.getState().purgeWorktreeTerminalState([WT]) const s = store.getState() expect(s.agentStatusByPaneKey[PANE]).toBeUndefined() + expect(s.agentStatusEpoch).toBe(epochBefore + 1) expect(s.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() expect(s.acknowledgedAgentsByPaneKey[PANE]).toBeUndefined() expect(s.paneForegroundAgentByPaneKey[PANE]).toBeUndefined() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 315e23aaf70..17b3444fe4d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2102,6 +2102,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial Date: Sat, 11 Jul 2026 13:59:28 -0700 Subject: [PATCH 11/26] Prune detected worktree scan generation markers (#7679) * Prune detected worktree scan generations * Invalidate pending scans in test reset hook Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/worktrees.test.ts | 199 ++++++++++++++++++++++++++++++++- src/main/ipc/worktrees.ts | 51 ++++++--- 2 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index db5bf3b1a4a..031f9cf703d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -246,7 +246,11 @@ import { notifyWorktreesChanged } from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { + __getDetectedWorktreeScanCacheStatsForTests, + __resetDetectedWorktreeScanCacheForTests, + registerWorktreeHandlers +} from './worktrees' type HandlerMap = Record unknown> @@ -2633,6 +2637,199 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(1) }) + it('does not retain invalidated detected scans after they settle', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 1 + }) + + notifyWorktreesChanged(mainWindow as never, 'repo-1') + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + await pendingList + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + }) + + it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { + store.getRepo.mockImplementation((repoId: string) => ({ + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + })) + listWorktreesMock.mockImplementation(async (repoPath: string) => [ + { + path: repoPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + for (let index = 0; index < 128; index += 1) { + const repoId = `repo-${index}` + await handlers['worktrees:listDetected'](null, { repoId }) + notifyWorktreesChanged(mainWindow as never, repoId) + } + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 0 + }) + expect(listWorktreesMock).toHaveBeenCalledTimes(128) + }) + + it('keeps a replacement scan current after an older scan settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + const result = [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[0](result) + await staleList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 1 + }) + + resolvers[1](result) + await replacementList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + + it('does not let an older scan overwrite a replacement that settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/fresh-worktree': { + worktreeId: 'repo-1::/workspace/fresh-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + const mainWorktree: GitWorktreeInfo = { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[1]([ + mainWorktree, + { + path: '/workspace/fresh-worktree', + head: 'fresh-head', + branch: 'refs/heads/fresh-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await replacementList + + resolvers[0]([ + mainWorktree, + { + path: '/workspace/stale-worktree', + head: 'stale-head', + branch: 'refs/heads/stale-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await staleList + + const cached = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/fresh-worktree' + ]) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + await expect( + resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never) + ).resolves.toBe(resolve('/workspace/fresh-worktree')) + await expect( + resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never) + ).rejects.toThrow('Access denied: unknown repository or worktree path') + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index df0dfd4c585..6aeb1144313 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -469,37 +469,59 @@ type DetectedWorktreeScanCacheEntry = { worktrees: GitWorktreeInfo[] } +type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean } const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map>() -const detectedWorktreeScanGenerations = new Map() +const detectedWorktreeScanInFlight = new Map() function invalidateDetectedWorktreeScanCache(repoId: string): void { const keyPrefix = `${repoId}\0` for (const key of new Set([ ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys(), - ...detectedWorktreeScanGenerations.keys() + ...detectedWorktreeScanInFlight.keys() ])) { if (!key.startsWith(keyPrefix)) { continue } detectedWorktreeScanCache.delete(key) - detectedWorktreeScanInFlight.delete(key) - detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token, so later scans can settle + // without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } } } registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: scans still pending across a test reset must not repopulate the + // cache afterward and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } detectedWorktreeScanCache.clear() detectedWorktreeScanInFlight.clear() - detectedWorktreeScanGenerations.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } } async function listDetectedGitWorktrees( @@ -522,24 +544,25 @@ async function listDetectedGitWorktrees( const inFlight = detectedWorktreeScanInFlight.get(cacheKey) if (inFlight) { - return { gitWorktrees: await inFlight, fresh: false } + return { gitWorktrees: await inFlight.promise, fresh: false } } - const scan = listRepoWorktrees(repo, localWorktreeGitOptions) - const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0 + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } detectedWorktreeScanInFlight.set(cacheKey, scan) try { - const gitWorktrees = await scan + const gitWorktrees = await scan.promise // Why: a create/remove notification can invalidate while the git scan is // still running. Do not let that stale scan repopulate the cache afterward. - const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation - if (isCurrentGeneration) { + if (!scan.invalidated) { detectedWorktreeScanCache.set(cacheKey, { worktrees: gitWorktrees, expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS }) } - return { gitWorktrees, fresh: isCurrentGeneration } + return { gitWorktrees, fresh: !scan.invalidated } } finally { if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { detectedWorktreeScanInFlight.delete(cacheKey) From d8a9da7351d08f6e3378aad7b4763c90ea771745 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:03:14 -0700 Subject: [PATCH 12/26] Fix PR comment selection persistence leak (#7688) Bound the PR/MR queued-comment selection cache to 1024 LRU contexts and make cache recency commit-safe so long renderer sessions cannot retain unbounded unsent selections. --- .../pr-comments-list-selection.test.tsx | 143 ++++++++++++++++-- .../pr-comments-list-selection.ts | 123 ++++++++++----- 2 files changed, 218 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index ec89aa87288..a7b9af2153f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +import { act, StrictMode, Suspense, type ReactNode } 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' @@ -46,7 +48,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' import { - clearPRCommentsListSelection, + MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS, + clearPRCommentsListSelectionsForTests, + getPRCommentsListSelectionCountForTests, + seedPRCommentsListSelectionForTests, type PRCommentsListSelectionClearRequest } from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' @@ -55,7 +60,7 @@ let container: HTMLDivElement let root: Root beforeEach(() => { - clearPRCommentsListSelection('review:42') + clearPRCommentsListSelectionsForTests() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -66,6 +71,7 @@ afterEach(() => { root.unmount() }) container.remove() + clearPRCommentsListSelectionsForTests() }) function comment(overrides: Partial): PRComment { @@ -83,20 +89,47 @@ function comment(overrides: Partial): PRComment { function renderList(props: { comments: PRComment[] contextKey?: string + strictMode?: boolean onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void clearRequest?: PRCommentsListSelectionClearRequest | null }): void { + const list = ( + + + + ) + act(() => { + root.render(props.strictMode ? {list} : list) + }) +} + +const neverSettles = new Promise(() => {}) + +function SuspendForever(): ReactNode { + throw neverSettles +} + +function renderAbandonedList(comments: PRComment[], contextKey: string): void { act(() => { root.render( - - - + Loading review}> + + + + + ) }) } @@ -303,6 +336,23 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('drops an empty selection from the persisted context cache', () => { + renderList({ + comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] + }) + clickButton('Queue for agent') + + const selectedCheckbox = container.querySelector( + 'button[role="checkbox"][aria-checked="true"]' + ) + expect(selectedCheckbox).not.toBeNull() + act(() => { + selectedCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(getPRCommentsListSelectionCountForTests()).toBe(0) + }) + it('clears sent standalone bot comments from the queue when the parent confirms launch', () => { const comments = [ comment({ @@ -378,5 +428,74 @@ describe('PRCommentsList comment resolution selection', () => { }) expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(getPRCommentsListSelectionCountForTests()).toBe(0) + }) + + it('keeps GitHub and GitLab review selections isolated under Strict Mode replay', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts' })] + const githubContext = 'local::repo::branch::github::42::github-head' + const gitlabContext = 'local::repo::branch::gitlab::42::gitlab-head' + + renderList({ comments, contextKey: githubContext, strictMode: true }) + clickButton('Queue for agent') + renderList({ comments, contextKey: gitlabContext, strictMode: true }) + clickButton('Queue for agent') + + expect(getPRCommentsListSelectionCountForTests()).toBe(2) + renderList({ comments, contextKey: githubContext, strictMode: true }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + renderList({ comments, contextKey: gitlabContext, strictMode: true }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + }) + + it('does not refresh LRU recency for an abandoned Suspense render', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts' })] + const queuedGroupIds = ['thread:thread-1'] as const + const seedContext = (contextKey: string): void => { + seedPRCommentsListSelectionForTests(contextKey, queuedGroupIds) + } + + seedContext('review:oldest') + for (let i = 0; i < MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS - 1; i += 1) { + seedContext(`review:recent-${i}`) + } + + renderAbandonedList(comments, 'review:oldest') + expect(container.textContent).toContain('Loading review') + seedContext('review:new') + + renderList({ comments, contextKey: 'review:oldest' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + renderList({ comments, contextKey: 'review:recent-0' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + }) + + it('bounds persisted review contexts while retaining recently restored selections', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] + const queuedGroupIds = ['thread:thread-1'] as const + const seedContext = (contextKey: string): void => { + seedPRCommentsListSelectionForTests(contextKey, queuedGroupIds) + } + + seedContext('review:keep') + for (let i = 0; i < MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS - 1; i += 1) { + seedContext(`review:stale-${i}`) + } + + // Why: committed remount must refresh LRU recency for the restored context. + renderList({ comments, contextKey: 'review:keep' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + seedContext('review:new') + + expect(getPRCommentsListSelectionCountForTests()).toBe( + MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS + ) + + renderList({ comments, contextKey: 'review:stale-0' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + + renderList({ comments, contextKey: 'review:keep' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) }) }) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 71b51ad5b3f..6767e405c05 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -26,26 +26,53 @@ type PRCommentsListSelectionState = { } const EMPTY_SELECTED_GROUP_IDS = new Set() +// Why: queued selections need to survive sidebar remounts, but old PR/MR +// contexts can disappear without another clear signal in a long renderer run. +export const MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS = 1024 const persistedSelectionByContextKey = new Map< string, { isSelectingForAI: boolean; selectedGroupIds: Set } >() +function trimPersistedSelectionContexts(): void { + while (persistedSelectionByContextKey.size > MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS) { + const oldestContextKey = persistedSelectionByContextKey.keys().next().value + if (oldestContextKey === undefined) { + break + } + persistedSelectionByContextKey.delete(oldestContextKey) + } +} + function persistSelectionState(state: PRCommentsListSelectionState): void { if (!state.contextKey) { return } - if (!state.isSelectingForAI && state.selectedGroupIds.size === 0) { + if (state.selectedGroupIds.size === 0) { persistedSelectionByContextKey.delete(state.contextKey) return } + persistedSelectionByContextKey.delete(state.contextKey) persistedSelectionByContextKey.set(state.contextKey, { isSelectingForAI: state.isSelectingForAI, selectedGroupIds: new Set(state.selectedGroupIds) }) + trimPersistedSelectionContexts() } -function createSelectionState(contextKey: string | undefined): PRCommentsListSelectionState { +function refreshPersistedSelectionContext(contextKey: string | undefined): void { + if (!contextKey) { + return + } + const persisted = persistedSelectionByContextKey.get(contextKey) + if (!persisted) { + return + } + persistedSelectionByContextKey.delete(contextKey) + persistedSelectionByContextKey.set(contextKey, persisted) +} + +function readSelectionState(contextKey: string | undefined): PRCommentsListSelectionState { const persisted = contextKey ? persistedSelectionByContextKey.get(contextKey) : undefined return { contextKey, @@ -60,20 +87,48 @@ export function clearPRCommentsListSelection(contextKey: string | undefined): vo } } +export function clearPRCommentsListSelectionsForTests(): void { + persistedSelectionByContextKey.clear() +} + +export function getPRCommentsListSelectionCountForTests(): number { + return persistedSelectionByContextKey.size +} + +// Why: bound/LRU tests need to fill the cache without 1024 React mounts per case. +export function seedPRCommentsListSelectionForTests( + contextKey: string, + selectedGroupIds: Iterable, + isSelectingForAI = true +): void { + persistSelectionState({ + contextKey, + isSelectingForAI, + selectedGroupIds: new Set(selectedGroupIds) + }) +} + export function usePRCommentsListSelection( comments: PRComment[], selectionContextKey: string | undefined, clearRequest?: PRCommentsListSelectionClearRequest | null ): PRCommentsListSelection { const lastClearRequestTokenRef = useRef(null) - const [selectionState, setSelectionState] = useState(() => - createSelectionState(selectionContextKey) - ) + const [renderedSelectionState, setRenderedSelectionState] = + useState(() => readSelectionState(selectionContextKey)) + const selectionState = + renderedSelectionState.contextKey === selectionContextKey + ? renderedSelectionState + : readSelectionState(selectionContextKey) + const commitSelectionState = useCallback((next: PRCommentsListSelectionState): void => { + persistSelectionState(next) + setRenderedSelectionState(next) + }, []) useEffect(() => { - setSelectionState((prev) => - prev.contextKey === selectionContextKey ? prev : createSelectionState(selectionContextKey) - ) + // Why: only a committed context may affect LRU order; render can be + // abandoned or replayed by Strict Mode/Suspense. + refreshPersistedSelectionContext(selectionContextKey) }, [selectionContextKey]) useEffect(() => { @@ -90,9 +145,8 @@ export function usePRCommentsListSelection( isSelectingForAI: false, selectedGroupIds: new Set() } - persistSelectionState(next) - setSelectionState(next) - }, [clearRequest, selectionContextKey]) + commitSelectionState(next) + }, [clearRequest, commitSelectionState, selectionContextKey]) // Why: selectable groups come from the unfiltered list so switching the // audience filter doesn't silently drop already-selected comments. @@ -138,10 +192,10 @@ export function usePRCommentsListSelection( isSelectingForAI: selectionState.isSelectingForAI, selectedGroupIds: new Set(selectedGroupIds) } - persistSelectionState(next) - setSelectionState(next) + commitSelectionState(next) }, [ candidateSelectedGroupIds, + commitSelectionState, comments.length, isCurrentSelectionContext, selectedGroupIds, @@ -169,10 +223,9 @@ export function usePRCommentsListSelection( isSelectingForAI: true, selectedGroupIds: new Set([groupId]) } - persistSelectionState(next) - setSelectionState(next) + commitSelectionState(next) }, - [selectableGroupsById, selectionContextKey] + [commitSelectionState, selectableGroupsById, selectionContextKey] ) const clearSelection = useCallback((): void => { @@ -181,34 +234,32 @@ export function usePRCommentsListSelection( isSelectingForAI: false, selectedGroupIds: new Set() } - persistSelectionState(next) - setSelectionState(next) - }, [selectionContextKey]) + commitSelectionState(next) + }, [commitSelectionState, selectionContextKey]) const toggleGroupSelection = useCallback( (groupId: string, checked: boolean): void => { if (!selectableGroupsById.has(groupId)) { return } - setSelectionState((prev) => { - const base = - prev.contextKey === selectionContextKey ? prev.selectedGroupIds : EMPTY_SELECTED_GROUP_IDS - const next = new Set([...base].filter((id) => selectableGroupsById.has(id))) - if (checked) { - next.add(groupId) - } else { - next.delete(groupId) - } - const nextState = { - contextKey: selectionContextKey, - isSelectingForAI: true, - selectedGroupIds: next - } - persistSelectionState(nextState) - return nextState + const current = readSelectionState(selectionContextKey) + const base = + current.contextKey === selectionContextKey + ? current.selectedGroupIds + : EMPTY_SELECTED_GROUP_IDS + const next = new Set([...base].filter((id) => selectableGroupsById.has(id))) + if (checked) { + next.add(groupId) + } else { + next.delete(groupId) + } + commitSelectionState({ + contextKey: selectionContextKey, + isSelectingForAI: true, + selectedGroupIds: next }) }, - [selectableGroupsById, selectionContextKey] + [commitSelectionState, selectableGroupsById, selectionContextKey] ) return { From 3804f75f6a2862cf064625610def9ab515704e03 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:03:49 -0700 Subject: [PATCH 13/26] fix native chat cleanup registration leak (#7663) --- .../native-chat-subscribe-lifecycle.test.ts | 228 ++++++++++++++++++ src/main/ipc/native-chat.test.ts | 62 +++++ src/main/ipc/native-chat.ts | 89 +++++-- 3 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 src/main/ipc/native-chat-subscribe-lifecycle.test.ts diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..53588814848 --- /dev/null +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + subscribeTranscript: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +vi.mock('../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: subscribeTranscript +})) + +import { + _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatSenderCleanupCountForTest, + clearNativeChatSubscriptions, + registerNativeChatHandlers +} from './native-chat' + +type TestSubscription = { + unsubscribe: ReturnType +} + +type DeferredSubscription = { + promise: Promise + reject: (error: Error) => void + resolve: () => void + unsubscribe: ReturnType +} + +type SenderHarness = { + destroy: () => void + registeredCleanupCount: () => number + sender: { + id: number + isDestroyed: () => boolean + once: (event: string, callback: () => void) => void + send: ReturnType + } +} + +beforeEach(() => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + subscribeTranscript.mockReset() + registerNativeChatHandlers() +}) + +function deferredSubscription(): DeferredSubscription { + const unsubscribe = vi.fn() + let resolvePromise: (subscription: TestSubscription) => void = () => {} + let rejectPromise: (error: Error) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + reject: rejectPromise, + resolve: () => resolvePromise({ unsubscribe }), + unsubscribe + } +} + +function createSender(id: number): SenderHarness { + let destroyed = false + const destroyedCallbacks: (() => void)[] = [] + return { + destroy: () => { + destroyed = true + for (const callback of destroyedCallbacks) { + callback() + } + }, + registeredCleanupCount: () => destroyedCallbacks.length, + sender: { + id, + isDestroyed: () => destroyed, + once: (event, callback) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }, + send: vi.fn() + } + } +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:subscribe') + if (!listener) { + throw new Error('subscribe listener not registered') + } + listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) +} + +function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:unsubscribe') + if (!listener) { + throw new Error('unsubscribe listener not registered') + } + listener({ sender }, { subscriptionId }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('timed out waiting for lifecycle state') + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('nativeChat subscribe lifecycle', () => { + it('closes a watcher that resolves after renderer unsubscribe', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(1) + + subscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + unsubscribe(renderer.sender, 'unmount') + expect(pending.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('closes a watcher that resolves after renderer destruction', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(2) + + subscribe(renderer.sender, 'destroy') + renderer.destroy() + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + }) + + it('keeps the latest same-id subscribe when setup resolves in reverse order', async () => { + const older = deferredSubscription() + const newer = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + const renderer = createSender(3) + + subscribe(renderer.sender, 'same-id') + subscribe(renderer.sender, 'same-id') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + + newer.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(newer.unsubscribe).not.toHaveBeenCalled() + older.resolve() + await waitFor(() => older.unsubscribe.mock.calls.length === 1) + + unsubscribe(renderer.sender, 'same-id') + expect(newer.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + }) + + it('clears rejected setup without duplicating sender cleanup registration', async () => { + const failed = deferredSubscription() + const retry = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(failed.promise).mockReturnValueOnce(retry.promise) + const renderer = createSender(4) + + subscribe(renderer.sender, 'retry') + failed.reject(new Error('watch setup failed')) + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) + + subscribe(renderer.sender, 'retry') + expect(renderer.registeredCleanupCount()).toBe(1) + retry.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + unsubscribe(renderer.sender, 'retry') + expect(retry.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('isolates late setup from a replacement renderer reusing the sender id', async () => { + const oldPending = deferredSubscription() + const replacementPending = deferredSubscription() + subscribeTranscript + .mockReturnValueOnce(oldPending.promise) + .mockReturnValueOnce(replacementPending.promise) + const oldRenderer = createSender(41) + const replacementRenderer = createSender(41) + + subscribe(oldRenderer.sender, 'remount') + oldRenderer.destroy() + subscribe(replacementRenderer.sender, 'remount') + expect(replacementRenderer.registeredCleanupCount()).toBe(1) + + replacementPending.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + oldPending.resolve() + await waitFor(() => oldPending.unsubscribe.mock.calls.length === 1) + expect(replacementPending.unsubscribe).not.toHaveBeenCalled() + + replacementRenderer.destroy() + expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) +}) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index fd09a14ff3f..d16a39b1f13 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + _getNativeChatSenderCleanupCountForTest, clearNativeChatSubscriptions, clearNativeChatTranscriptCache, registerNativeChatHandlers @@ -238,6 +239,67 @@ describe('nativeChat:readSession handler', () => { } }) + it('drops cleanup registration when sender is destroyed before subscribe completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-race.jsonl'), + `${jsonLines([ + { + type: 'user', + uuid: 'u-race', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Race' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + let destroyed = false + let destroyedCb: (() => void) | undefined + const sender = { + id: 41, + isDestroyed: () => destroyed, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: vi.fn() + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-race', + agent: 'claude', + sessionId: 'sess-race' + } + ) + + expect(destroyedCb).toBeDefined() + destroyed = true + destroyedCb!() + + await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0) + expect(sender.send).not.toHaveBeenCalled() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('returns an error for an unknown session without throwing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index ad6117db0c1..3ed4e9b74a6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -71,9 +71,17 @@ type LiveSubscription = { // same renderer can watch several panes, and a destroyed window tears down all // of its watchers — strict teardown to avoid fd leaks (plan U4 risk). const liveSubscriptions = new Map>() +// Why: unsubscribe and renderer destruction must invalidate async watcher setup +// before it can publish a late subscription into the live map. +const pendingSubscriptions = new Map>() const senderCleanupRegistered = new Set() function teardownSubscription(senderId: number, subscriptionId: string): void { + const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.delete(subscriptionId) + if (pendingBySubId?.size === 0) { + pendingSubscriptions.delete(senderId) + } const bySubId = liveSubscriptions.get(senderId) const live = bySubId?.get(subscriptionId) if (!live || !bySubId) { @@ -87,6 +95,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { } function teardownAllForSender(senderId: number): void { + // The destroyed event can arrive before async subscription setup stores a watcher. + senderCleanupRegistered.delete(senderId) + pendingSubscriptions.delete(senderId) const bySubId = liveSubscriptions.get(senderId) if (!bySubId) { return @@ -95,7 +106,6 @@ function teardownAllForSender(senderId: number): void { live.subscription.unsubscribe() } liveSubscriptions.delete(senderId) - senderCleanupRegistered.delete(senderId) } function registerSenderCleanup(sender: WebContents): void { @@ -107,6 +117,27 @@ function registerSenderCleanup(sender: WebContents): void { sender.once('destroyed', () => teardownAllForSender(sender.id)) } +function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { + teardownSubscription(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, token) + pendingSubscriptions.set(senderId, bySubId) + return token +} + +function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubId = pendingSubscriptions.get(senderId) + if (bySubId?.get(subscriptionId) !== token) { + return false + } + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + pendingSubscriptions.delete(senderId) + } + return true +} + async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender if (sender.isDestroyed()) { @@ -114,26 +145,32 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } const { subscriptionId, agent, sessionId, transcriptPath } = args // Replace any prior subscription under the same id (session change/resubscribe). - teardownSubscription(sender.id, subscriptionId) + const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) - const subscription = await subscribeNativeChatTranscript({ - agent, - sessionId, - transcriptPath, - onAppend: (messages) => { - if (sender.isDestroyed()) { - return + let subscription: NativeChatTranscriptSubscription + try { + subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } - sender.send('nativeChat:appended', payload) - } - }) + }) + } catch { + takePendingSubscription(sender.id, subscriptionId, pendingToken) + return + } - // The window may have gone away (or the subscription been replaced) while we - // resolved the file path — don't register a now-orphaned watcher. - const stillCurrent = !sender.isDestroyed() - if (!stillCurrent) { + // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup + // while path resolution is pending; only the owning token may publish its watcher. + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } @@ -147,12 +184,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg liveSubscriptions.set(sender.id, bySubId) } -/** Test-only: drop all live transcript subscriptions between runs. */ +/** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = Array.from(liveSubscriptions.keys()) + const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) for (const senderId of senderIds) { teardownAllForSender(senderId) } + pendingSubscriptions.clear() + senderCleanupRegistered.clear() +} + +export function _getNativeChatSenderCleanupCountForTest(): number { + return senderCleanupRegistered.size +} + +export function _getNativeChatPendingSubscriptionCountForTest(): number { + let count = 0 + for (const bySubId of pendingSubscriptions.values()) { + count += bySubId.size + } + return count } export function registerNativeChatHandlers(): void { From b18ec39e1f337b3771cb5ac10416e8487731f1d5 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sat, 11 Jul 2026 14:14:12 -0700 Subject: [PATCH 14/26] fix(naming): lead workspace and tab names with the work-item identifier (#8238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(naming): lead workspace and tab names with the work-item identifier Auto-generated workspace names, branch-rename display names, and tab titles now lead with the referenced PR/MR/issue/ticket — e.g. `PR 1033 - Review` instead of a paraphrase like "Review community pr 1094" that buried or dropped the number. Identifiers are the highest- signal, most searchable token, so leading with them makes the sidebar and tabs scannable and disambiguates same-verb work items. - New shared `work-item-reference.ts` extracts the identifier from the raw prompt: URLs are validated by path structure (owner/repo/pull/N, GitLab's `/-/` marker) so GitHub Enterprise / self-hosted GitLab still resolve while stray `/pull/` paths (CDN, docs) do not; a ticket- prefix denylist keeps `SHA-256` / `UTF-8` / `ISO-8601` from being read as Jira/Linear keys. - Reconciles the existing create-from-work-item naming (was action-first `Review PR 1033`) with the first-work auto-rename onto one identifier- first format via a shared `formatIdentifierFirst`, so the two paths can't drift. - Fixes a pre-existing tab-title bug where markdown punctuation was stripped before URLs, splitting a GitLab `merge_requests` URL at its underscore and leaking "requests" into the title. * fix(naming): keep emphasis-wrapped URLs intact in generated names The URL/markdown strip reorder left the tab-title URL strip anchored on \b, which fails when a URL is wrapped in markdown emphasis (_...pull/5_) and leaked URL fragments into the title. Drop the \b anchor and trim trailing markdown emphasis (*_~) in the URL identifier parser, keeping interior underscores (merge_requests) intact. Adds regression tests. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../agent-hooks/first-work-branch-rename.ts | 6 +- .../src/lib/launch-work-item-direct.test.ts | 8 +- src/shared/agent-tab-title.test.ts | 42 ++++- src/shared/agent-tab-title.ts | 25 ++- src/shared/display-name-from-work.test.ts | 60 ++++++ src/shared/display-name-from-work.ts | 76 ++++++++ src/shared/work-item-reference.test.ts | 102 ++++++++++ src/shared/work-item-reference.ts | 174 ++++++++++++++++++ src/shared/workspace-name.test.ts | 12 +- src/shared/workspace-name.ts | 6 +- 10 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 src/shared/display-name-from-work.test.ts create mode 100644 src/shared/display-name-from-work.ts create mode 100644 src/shared/work-item-reference.test.ts create mode 100644 src/shared/work-item-reference.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index fabf508b141..2b734fd41ca 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -8,10 +8,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree- import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { - humanizeBranchSlug, isAutoGeneratedCreatureBranchName, stripConfiguredBranchPrefix } from '../../shared/branch-name-from-work' +import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' @@ -307,7 +307,7 @@ async function runAutoRename( // Keep the sidebar name in sync with the branch — but only when it is still // the auto-generated creature name, so a name the user typed is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) - const newDisplayName = humanizeBranchSlug(newBranchLeaf) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) if (updateDisplay) { deps.setDisplayName(worktreeId, newDisplayName) @@ -390,7 +390,7 @@ async function runFolderWorkspaceTitleAutoRename( return retry(`generation failed: ${generated.error}`) } - const newDisplayName = humanizeBranchSlug(generated.slug) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) deps.setDisplayName(worktreeId, newDisplayName) deps.setRenameError(worktreeId, null) deps.onRenamed(worktreeId) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 77d8b44e062..04da6b1d341 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -77,11 +77,11 @@ vi.mock('@/lib/new-workspace', () => ({ ? { displayName: args.workItem.type === 'pr' - ? `Review PR ${args.workItem.number}` + ? `PR ${args.workItem.number} - Review` : `Issue ${args.workItem.number}`, seedName: args.workItem.type === 'pr' - ? `review-pr-${args.workItem.number}` + ? `pr-${args.workItem.number}-review` : `issue-${args.workItem.number}` } : null, @@ -253,12 +253,12 @@ describe('launchWorkItemDirect', () => { }) expect(mocks.createWorktree).toHaveBeenCalledWith( 'repo-1', - 'review-pr-6934', + 'pr-6934-review', 'abc123', 'inherit', undefined, 'sidebar', - 'Review PR 6934', + 'PR 6934 - Review', undefined, 6934, { remoteName: 'origin', branchName: 'feature/fix' }, diff --git a/src/shared/agent-tab-title.test.ts b/src/shared/agent-tab-title.test.ts index c931052201d..6ee20010459 100644 --- a/src/shared/agent-tab-title.test.ts +++ b/src/shared/agent-tab-title.test.ts @@ -28,9 +28,47 @@ describe('deriveGeneratedTabTitle', () => { ) }) - it('keeps useful text after common issue prefixes', () => { + it('leads with the issue identifier and keeps the description after it', () => { expect(deriveGeneratedTabTitle('Issue #2056: Opt-in generated tab titles for agents')).toBe( - 'Opt in generated tab titles for agents' + 'Issue 2056 - Opt in generated tab' + ) + }) + + it('recovers a PR number from a URL the pipeline strips, leading with it', () => { + expect( + deriveGeneratedTabTitle( + 'Review this community PR https://github.com/EveryInc/plugin/pull/1094' + ) + ).toBe('PR 1094 - Review this community') + }) + + it('leads with a GitLab MR identifier', () => { + expect( + deriveGeneratedTabTitle('fix https://gitlab.com/group/app/-/merge_requests/42 quickly') + ).toBe('MR 42 - Fix quickly') + }) + + it('strips a URL containing underscores intact', () => { + expect( + deriveGeneratedTabTitle('inspect https://gitlab.com/g/p/-/work_items/9 then report') + ).toBe('Issue 9 - Inspect then report') + }) + + it('leads with the identifier for a URL wrapped in markdown emphasis, without leaking fragments', () => { + const title = deriveGeneratedTabTitle('Review _https://github.com/o/r/pull/5_ now') + expect(title).toBe('PR 5 - Review now') + expect(title).not.toMatch(/https|pull/) + }) + + it('leads with a bare ticket key', () => { + expect(deriveGeneratedTabTitle('implement ENG-456 login flow')).toBe( + 'ENG-456 - Implement login flow' + ) + }) + + it('does not treat a cipher token as an identifier', () => { + expect(deriveGeneratedTabTitle('implement SHA-256 hashing in the signer')).toBe( + 'Implement SHA 256 hashing in the signer' ) }) diff --git a/src/shared/agent-tab-title.ts b/src/shared/agent-tab-title.ts index 9ff08dcce71..2060409fbd7 100644 --- a/src/shared/agent-tab-title.ts +++ b/src/shared/agent-tab-title.ts @@ -1,3 +1,9 @@ +import { + extractWorkIdentifier, + formatIdentifierFirst, + stripWorkIdentifierEcho +} from './work-item-reference' + export const GENERATED_TAB_TITLE_MAX_LENGTH = 40 export const GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT = 512 @@ -74,9 +80,14 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { const promptPreview = prompt.slice(0, GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT) const firstClause = promptPreview .trim() + // Strip URLs before markdown punctuation: a GitLab URL like + // `/merge_requests/42` contains `_`, and folding that to a space first would + // split the URL and leak fragments ("requests") into the title. No `\b` + // anchor: a URL wrapped in markdown emphasis (`_https://…_`) is preceded by + // a word char, where `\bhttps` would fail to match and leak the whole URL. + .replace(/https?:\/\/\S+/gi, ' ') .replace(/[`*_~#>[\]{}()]/g, ' ') .replace(/^(?:issue|task|bug|feature|pr)\s*(?:#?\d+)?\s*[:-]\s*/i, '') - .replace(/\bhttps?:\/\/\S+/gi, ' ') .split(/[.!?;\n\r\u2028\u2029]/u)[0] ?.trim() @@ -98,6 +109,18 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { candidate = foldGeneratedTabTitleWhitespace(candidate.replace(/[^\p{L}\p{N}\s]/gu, ' ')) + // Lead with the review target (`PR 1094 - …`) so the tab matches the sidebar + // workspace name; the pipeline above strips the URL/prefix, so the number is + // recovered from the raw prompt rather than the cleaned clause. + const identifier = extractWorkIdentifier(promptPreview) + if (identifier) { + const detail = capitalizeFirstLetter(stripWorkIdentifierEcho(candidate, identifier)) + return truncateAtWordBoundary( + formatIdentifierFirst(identifier.label, detail), + GENERATED_TAB_TITLE_MAX_LENGTH + ) + } + if (!candidate) { return null } diff --git a/src/shared/display-name-from-work.test.ts b/src/shared/display-name-from-work.test.ts new file mode 100644 index 00000000000..ea467e3ea3b --- /dev/null +++ b/src/shared/display-name-from-work.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkspaceDisplayName } from './display-name-from-work' + +// Identifier extraction itself is covered in work-item-reference.test.ts; these +// cases exercise the end-to-end display-name composition. +describe('deriveWorkspaceDisplayName', () => { + it('leads with the identifier and a single action verb', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'Carefully evaluate https://github.com/o/r/pull/1033. Fix the merge conflict.', + slug: 'review-community-pr-conflict' + }) + ).toBe('PR 1033 - Review') + }) + + it('drops identifier tokens the slug also carried', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'look at this community PR https://github.com/o/r/pull/1094', + slug: 'review-community-pr-1094' + }) + ).toBe('PR 1094 - Review') + }) + + it('uses a namespaced ticket id bare, without a type prefix', () => { + expect( + deriveWorkspaceDisplayName({ prompt: 'fix ENG-456 crash', slug: 'fix-eng-456-crash' }) + ).toBe('ENG-456 - Fix') + }) + + it('returns the identifier alone when no action word survives', () => { + expect(deriveWorkspaceDisplayName({ prompt: 'PR 12', slug: 'pr-12' })).toBe('PR 12') + }) + + it('carries a collision suffix so same-target worktrees stay distinct', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'review https://github.com/o/r/pull/1033', + slug: 'review-conflict', + resolvedLeaf: 'review-conflict-2' + }) + ).toBe('PR 1033 - Review (2)') + }) + + it('falls back to the humanized leaf when no identifier is present', () => { + expect( + deriveWorkspaceDisplayName({ prompt: 'add a dark mode toggle', slug: 'add-dark-mode-toggle' }) + ).toBe('Add dark mode toggle') + }) + + it('humanizes the resolved leaf (with suffix) on the fallback path', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'add a logout button', + slug: 'add-logout-button', + resolvedLeaf: 'add-logout-button-2' + }) + ).toBe('Add logout button 2') + }) +}) diff --git a/src/shared/display-name-from-work.ts b/src/shared/display-name-from-work.ts new file mode 100644 index 00000000000..65889b8417c --- /dev/null +++ b/src/shared/display-name-from-work.ts @@ -0,0 +1,76 @@ +import { humanizeBranchSlug } from './branch-name-from-work' +import { extractWorkIdentifier, formatIdentifierFirst } from './work-item-reference' + +// Why: the git branch must stay lowercase kebab-case, but the sidebar display +// name is free-form. When the first prompt references a review target, that +// identifier is the highest-signal, most searchable token — so we lead with it +// (`PR 1033 - Review`) instead of a paraphrased slug like "Review community pr +// 1033". The identifier comes from the raw prompt (via work-item-reference), +// where the number still exists, not from the lossy branch slug. + +// Type words that are never a useful action verb, skipped even if a slug leads +// with one (e.g. `pr-1094-review` → `Review`, not `Pr`). +const ACTION_STOPWORDS = new Set([ + 'pr', + 'mr', + 'pull', + 'merge', + 'request', + 'requests', + 'issue', + 'issues', + 'the', + 'a', + 'an', + 'to', + 'for', + 'of' +]) + +// The first slug word that is neither a digit nor an identifier/type token, +// capitalized — the task verb ("Review", "Fix"). '' when none remains. +function leadingActionWord(slug: string, identifierTokens: string[]): string { + const skip = new Set([...identifierTokens, ...ACTION_STOPWORDS]) + for (const word of slug.split('-').filter(Boolean)) { + if (/^\d+$/.test(word) || skip.has(word)) { + continue + } + return word.charAt(0).toUpperCase() + word.slice(1) + } + return '' +} + +// A `-N` collision suffix (`fix-auth` → `fix-auth-2`) that resolveUniqueBranchName +// appended, so two worktrees on the same identifier stay distinguishable. +function collisionSuffixFromLeaf( + baseSlug: string, + resolvedLeaf: string | undefined +): number | null { + if (!resolvedLeaf || resolvedLeaf === baseSlug || !resolvedLeaf.startsWith(`${baseSlug}-`)) { + return null + } + const rest = resolvedLeaf.slice(baseSlug.length + 1) + return /^\d+$/.test(rest) ? Number(rest) : null +} + +/** + * Build the sidebar display name for a freshly auto-renamed workspace. When the + * prompt names a review target, returns ` - ` (e.g. + * `PR 1033 - Review`); otherwise falls back to the plain humanized branch slug. + * `resolvedLeaf` is the final branch leaf (after any collision suffix); omit it + * for folder workspaces that have no git branch. + */ +export function deriveWorkspaceDisplayName(input: { + prompt: string + slug: string + resolvedLeaf?: string +}): string { + const identifier = extractWorkIdentifier(input.prompt) + if (!identifier) { + return humanizeBranchSlug(input.resolvedLeaf ?? input.slug) + } + const action = leadingActionWord(input.slug, identifier.tokens) + const base = formatIdentifierFirst(identifier.label, action) + const suffix = collisionSuffixFromLeaf(input.slug, input.resolvedLeaf) + return suffix ? `${base} (${suffix})` : base +} diff --git a/src/shared/work-item-reference.test.ts b/src/shared/work-item-reference.test.ts new file mode 100644 index 00000000000..c49d9f87812 --- /dev/null +++ b/src/shared/work-item-reference.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { extractWorkIdentifier, stripWorkIdentifierEcho } from './work-item-reference' + +describe('extractWorkIdentifier', () => { + it('reads a GitHub pull request URL', () => { + expect(extractWorkIdentifier('Review https://github.com/EveryInc/plugin/pull/1033')).toEqual({ + label: 'PR 1033', + tokens: ['pr', '1033'] + }) + }) + + it('reads a Bitbucket pull-requests URL', () => { + expect( + extractWorkIdentifier('Look at https://bitbucket.org/team/repo/pull-requests/77')?.label + ).toBe('PR 77') + }) + + it('reads a GitLab merge request URL as MR, and a work_items URL as an issue', () => { + expect(extractWorkIdentifier('Check https://gitlab.com/group/app/-/merge_requests/42')).toEqual( + { label: 'MR 42', tokens: ['mr', '42'] } + ) + expect(extractWorkIdentifier('https://gitlab.example.com/g/p/-/work_items/9')?.label).toBe( + 'Issue 9' + ) + }) + + it('reads an issue URL', () => { + expect(extractWorkIdentifier('Fix https://github.com/o/r/issues/88')?.label).toBe('Issue 88') + }) + + it('ignores URLs whose path only resembles a work item (no owner/repo, wrong host shape)', () => { + // A CDN asset path contains `/pull/2023` but is not a pull request. + expect( + extractWorkIdentifier('Load https://cdn.vendor.com/assets/pull/2023/data.json') + ).toBeNull() + // No trailing number after the item segment. + expect(extractWorkIdentifier('see https://github.com/o/r/pull/notanumber')).toBeNull() + }) + + it('tolerates trailing punctuation around a URL', () => { + expect(extractWorkIdentifier('(see https://github.com/o/r/pull/5).')?.label).toBe('PR 5') + }) + + it('reads a URL wrapped in markdown emphasis (trailing underscore/asterisk)', () => { + expect(extractWorkIdentifier('Review _https://github.com/o/r/pull/5_ now')?.label).toBe('PR 5') + expect(extractWorkIdentifier('Review **https://github.com/o/r/pull/1094**')?.label).toBe( + 'PR 1094' + ) + }) + + it('reads textual references', () => { + expect(extractWorkIdentifier('please review PR #1094')?.label).toBe('PR 1094') + expect(extractWorkIdentifier('triage pull request 500')?.label).toBe('PR 500') + expect(extractWorkIdentifier('reproduce issue 12')?.label).toBe('Issue 12') + expect(extractWorkIdentifier('handle merge request !9')?.label).toBe('MR 9') + }) + + it('reads a namespaced ticket id bare', () => { + expect(extractWorkIdentifier('implement ENG-456 login flow')).toEqual({ + label: 'ENG-456', + tokens: ['eng', '456'] + }) + }) + + it('does not treat standards, ciphers, or encodings as tickets', () => { + expect(extractWorkIdentifier('implement SHA-256 hashing')).toBeNull() + expect(extractWorkIdentifier('parse UTF-8 input')).toBeNull() + expect(extractWorkIdentifier('handle ISO-8601 dates')).toBeNull() + }) + + it('skips a denylisted prefix but still finds a real key after it', () => { + expect(extractWorkIdentifier('encrypt with AES-256 for ticket ENG-99')?.label).toBe('ENG-99') + }) + + it('prefers a provider URL over an incidental ticket-shaped token', () => { + expect( + extractWorkIdentifier('per RFC-2616 notes, review https://github.com/o/r/pull/7')?.label + ).toBe('PR 7') + }) + + it('falls back to a bare number, then to null', () => { + expect(extractWorkIdentifier('look at #321 when free')?.label).toBe('#321') + expect(extractWorkIdentifier('add a dark mode toggle to settings')).toBeNull() + }) +}) + +describe('stripWorkIdentifierEcho', () => { + it('removes the identifier tokens from a description', () => { + expect( + stripWorkIdentifierEcho('Review this community PR', { + label: 'PR 1094', + tokens: ['pr', '1094'] + }) + ).toBe('Review this community') + }) + + it('removes a ticket key echoed in the description', () => { + expect( + stripWorkIdentifierEcho('Fix ENG 456 crash', { label: 'ENG-456', tokens: ['eng', '456'] }) + ).toBe('Fix crash') + }) +}) diff --git a/src/shared/work-item-reference.ts b/src/shared/work-item-reference.ts new file mode 100644 index 00000000000..8fcf7e08725 --- /dev/null +++ b/src/shared/work-item-reference.ts @@ -0,0 +1,174 @@ +// Why: a single, host-aware parser for the review target named in a prompt (a +// PR, MR, issue, or ticket), shared by the sidebar workspace name and the tab +// title so both surface the same identifier. URLs are validated by *path +// structure* (owner/repo/pull/N, GitLab's `/-/` marker) rather than hostname, +// which keeps GitHub Enterprise and self-hosted GitLab working while rejecting +// stray URLs that merely contain `/pull/` (CDN assets, docs pages). + +export type WorkIdentifier = { + /** Human label, identifier-first, e.g. `PR 1033`, `MR 42`, `ENG-456`. */ + label: string + /** Lowercased identifier tokens, so consumers can drop them from a slug or + * description rather than echoing `Pr`, a bare number, or the ticket twice. */ + tokens: string[] +} + +// Prompts can be paste-sized, and a review target is named up front — so bound +// the scan to a prefix rather than running regexes over the whole prompt. +const IDENTIFIER_SCAN_LIMIT = 4096 + +// Uppercase prefixes that look like Jira/Linear keys but are standards, ciphers, +// or encodings — kept off the ticket path so `SHA-256` / `UTF-8` / `ISO-8601` +// don't become workspace names. Single-letter prefixes (`P-256`) can't match the +// two-letter-minimum pattern, so they need no entry here. +const NON_TICKET_PREFIXES = new Set([ + 'UTF', + 'SHA', + 'MD', + 'ISO', + 'RFC', + 'AES', + 'RSA', + 'EC', + 'ES', + 'RS', + 'HS', + 'PS', + 'GPT', + 'MPEG', + 'UTC', + 'GMT', + 'IPV', + 'IEEE', + 'ANSI', + 'ASCII', + 'TLS', + 'SSL', + 'HTTP', + 'HTTPS' +]) + +const URL_IN_TEXT = /https?:\/\/[^\s<>()[\]"']+/gi +// GitLab's project-internal `/-/` marker is unambiguous; a GitLab issue path +// also would not match the GitHub pattern, so ordering GitLab first is safe. +const GITLAB_ITEM_PATH = /\/-\/(issues|work_items|merge_requests)\/(\d+)(?:[/?#]|$)/i +const GITHUB_ITEM_PATH = /^\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)(?:[/?#]|$)/i +const BITBUCKET_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i + +function taggedIdentifier(type: 'PR' | 'MR' | 'Issue', num: string): WorkIdentifier { + return { label: `${type} ${num}`, tokens: [type.toLowerCase(), num] } +} + +function urlToIdentifier(raw: string): WorkIdentifier | null { + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + const path = url.pathname + const gitlab = GITLAB_ITEM_PATH.exec(path) + if (gitlab) { + return gitlab[1].toLowerCase() === 'merge_requests' + ? taggedIdentifier('MR', gitlab[2]) + : taggedIdentifier('Issue', gitlab[2]) + } + const github = GITHUB_ITEM_PATH.exec(path) + if (github) { + return github[1].toLowerCase() === 'pull' + ? taggedIdentifier('PR', github[2]) + : taggedIdentifier('Issue', github[2]) + } + const bitbucket = BITBUCKET_ITEM_PATH.exec(path) + if (bitbucket) { + return taggedIdentifier('PR', bitbucket[1]) + } + return null +} + +function findUrlIdentifier(text: string): WorkIdentifier | null { + const urls = text.match(URL_IN_TEXT) + if (!urls) { + return null + } + for (const raw of urls) { + // Trim trailing sentence punctuation and markdown emphasis (`_`/`*`/`~`): a + // URL wrapped like `_…/pull/5_` otherwise keeps the `_`, breaking the path + // anchor so the identifier is lost. Interior `_` (`merge_requests`) is kept. + const identifier = urlToIdentifier(raw.replace(/[.,;:!?*_~]+$/, '')) + if (identifier) { + return identifier + } + } + return null +} + +/** + * Pull the review-target identifier out of raw prompt text. Precedence runs from + * most reliable (provider URLs) to least (a bare `#123`), so a real URL wins over + * an incidental ticket-shaped token. Returns null when the prompt names none. + */ +export function extractWorkIdentifier(text: string): WorkIdentifier | null { + const scanned = text.slice(0, IDENTIFIER_SCAN_LIMIT) + + const urlIdentifier = findUrlIdentifier(scanned) + if (urlIdentifier) { + return urlIdentifier + } + + // Textual references ("pull request #12", "PR 12", "issue 88"). + let match = scanned.match(/\bmerge\s+request\s*[#!]?\s*(\d+)/i) + if (match) { + return taggedIdentifier('MR', match[1]) + } + match = scanned.match(/\bpull\s+request\s*#?\s*(\d+)/i) ?? scanned.match(/\bpr\s*#?\s*(\d+)/i) + if (match) { + return taggedIdentifier('PR', match[1]) + } + match = scanned.match(/\bissue\s*#?\s*(\d+)/i) + if (match) { + return taggedIdentifier('Issue', match[1]) + } + + // Namespaced ticket id (Jira/Linear), used bare. Uppercase-only so lowercase + // tokens like `gpt-4` don't match; skip standards/cipher prefixes, and keep + // scanning so a real key after one (e.g. `SHA-256 … ENG-456`) still resolves. + for (const ticket of scanned.matchAll(/\b([A-Z]{2,10})-(\d{1,7})\b/g)) { + if (!NON_TICKET_PREFIXES.has(ticket[1])) { + return { label: `${ticket[1]}-${ticket[2]}`, tokens: [ticket[1].toLowerCase(), ticket[2]] } + } + } + + // Bare `#123` as a last resort — identifier-first but provider-agnostic. + match = scanned.match(/(?:^|\s)#(\d+)\b/) + if (match) { + return { label: `#${match[1]}`, tokens: [match[1]] } + } + + return null +} + +/** + * Compose an identifier-first label — `PR 1033 - Review`, or just `PR 1033` when + * there is no trailing detail. The single source of the format shared by the + * sidebar name, tab title, and auto-rename name so they cannot drift apart. + */ +export function formatIdentifierFirst(label: string, detail: string): string { + return detail ? `${label} - ${detail}` : label +} + +/** + * Remove the identifier's own tokens from a description so a caller can prepend + * the label without echoing it — `PR 1094 - Review this PR` becomes + * `PR 1094 - Review this`. + */ +export function stripWorkIdentifierEcho(text: string, identifier: WorkIdentifier): string { + let stripped = text + for (const token of identifier.tokens) { + stripped = stripped.replace(new RegExp(`\\b${token}\\b`, 'gi'), ' ') + } + return stripped.replace(/\s+/g, ' ').trim() +} diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts index 10885fbf197..797d507d83c 100644 --- a/src/shared/workspace-name.test.ts +++ b/src/shared/workspace-name.test.ts @@ -95,8 +95,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Fix Issue 2635', - seedName: 'fix-issue-2635' + displayName: 'Issue 2635 - Fix', + seedName: 'issue-2635-fix' }) }) @@ -111,8 +111,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Review PR 1234', - seedName: 'review-pr-1234' + displayName: 'PR 1234 - Review', + seedName: 'pr-1234-review' }) expect( getWorkspaceIntentName({ @@ -125,8 +125,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Fix MR 77', - seedName: 'fix-mr-77' + displayName: 'MR 77 - Fix', + seedName: 'mr-77-fix' }) }) diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts index 7ba7629e350..f98958160b9 100644 --- a/src/shared/workspace-name.ts +++ b/src/shared/workspace-name.ts @@ -2,6 +2,7 @@ import { collectCompactWorkspaceWords, foldWorkspaceNameWhitespaceToHyphen } from './workspace-name-text-scanner' +import { formatIdentifierFirst } from './work-item-reference' function normalizeApostrophes(input: string): string { return input.replace(/[‘’]/g, "'") @@ -214,7 +215,10 @@ export function getWorkspaceIntentName(args: { const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item) const identity = workItemIdentity(item) if (action) { - displayName = `${action} ${identity}` + // Identifier-first so the sidebar leads with the searchable token + // (`PR 1033 - Review`); the shared formatIdentifierFirst keeps first-create, + // auto-rename, and tab-title names on one format. + displayName = formatIdentifierFirst(identity, action) } else { const subject = compactWorkItemTitle(item.title, item) displayName = [identity, subject].filter(Boolean).join(' ') From 800967278e61e29a75ead246aa245768ea1ba736 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:24:39 -0700 Subject: [PATCH 15/26] feat: promote APFS shared paths (#8318) --- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/ipc/worktree-remote.ts | 8 +-- src/main/runtime/orca-runtime.ts | 7 +-- .../components/settings/ExperimentalPane.tsx | 54 ------------------ .../components/settings/RepositoryPane.tsx | 2 - .../settings/experimental-search.ts | 55 ------------------- src/shared/constants.ts | 1 - src/shared/telemetry-events.ts | 1 - src/shared/types.ts | 15 +---- 10 files changed, 7 insertions(+), 138 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9958e0fe867..d3c39b0b067 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -142,7 +142,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a7cf0d033e2..8c4779573fc 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -147,7 +147,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e0fd8ee8fbc..6a5db1be3de 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1903,8 +1903,8 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for - // remote (SSH) worktrees. Creating symlinks on the remote host would + // Why: shared paths are intentionally not wired up for remote (SSH) + // worktrees. Creating symlinks on the remote host would // require a new relay method and authorization surface; the feature is // local-only until that protocol work is in scope. Remote repos with // `symlinkPaths` configured have them silently ignored here. @@ -2536,10 +2536,8 @@ export async function createLocalWorktree( // Why: materialize user-configured paths from the primary checkout into the // new worktree before any setup script runs, so scripts that reuse shared // state (e.g. `node_modules`, `.env`) see those paths already in place. - // Gated on the experimental flag so disabling the feature globally skips - // the work even when a repo still has paths configured. const symlinkPaths = repo.symlinkPaths ?? [] - if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) { + if (symlinkPaths.length > 0) { await timing.time('create_symlinks', async () => { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84f1954d194..2cf08c69f4d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -853,7 +853,6 @@ type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] - experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null @@ -15142,11 +15141,7 @@ export class OrcaRuntimeService { warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - if ( - settings.experimentalWorktreeSymlinks && - repo.symlinkPaths && - repo.symlinkPaths.length > 0 - ) { + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) } diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index f111d03d3b2..2aaa38d6bbb 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -43,9 +43,6 @@ export function ExperimentalPane({ const showTerminalAttention = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().terminalAttention ]) - const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry().symlinksOnWorktrees - ]) const showAgentHibernation = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().agentHibernation ]) @@ -322,57 +319,6 @@ export function ExperimentalPane({ ) : null} - {showWorktreeSymlinks ? ( - -
-
- -

- {translate( - 'auto.components.settings.ExperimentalPane.9762364929', - 'Uses APFS clone-copy on macOS when possible, otherwise symlinks configured folders or files into created worktrees.' - )} -

-
- -
-
- ) : null} - {hiddenExperimentalUnlocked ? : null} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 91bfa20045e..e05f13fae5d 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -81,7 +81,6 @@ export function RepositoryPane({ const runtimeSessionSummary = useAppStore( useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id)) ) - const symlinksEnabled = settings?.experimentalWorktreeSymlinks const [confirmingRemove, setConfirmingRemove] = useState(null) const [copiedTemplate, setCopiedTemplate] = useState(false) const copiedTemplateResetTimerRef = useRef(null) @@ -353,7 +352,6 @@ export function RepositoryPane({ ) : null, !isFolder && !repo.connectionId && - symlinksEnabled && (forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, symlinkEntries)) ? ( ) : null, diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index de746b3cb54..71ff93ed5b9 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -185,55 +185,6 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( ] }, getNewWorktreeCardStyleSearchEntry(), - { - title: translate( - 'auto.components.settings.experimental.search.78c2a8dc74', - 'Shared paths on worktrees' - ), - description: translate( - 'auto.components.settings.experimental.search.603d29ed74', - 'Automatically materialize configured files or folders into newly created worktrees using APFS clone-copy on macOS when possible, otherwise symlinks.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.d23ae13990', - 'worktree' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.10b52f79c1', - 'worktrees' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.c387565812', - 'symlink' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.bff1ff7768', - 'symlinks' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.3028f0bd3a', - 'link' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.f082788cfe', - 'links' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.3021571c30', - 'shared' - ), - ...translateSearchKeyword('auto.components.settings.experimental.search.4ad605f222', 'env'), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.44c7f209d5', - 'node_modules' - ) - ] - }, getEphemeralVmsSearchEntry() ] ) @@ -273,12 +224,6 @@ export function getExperimentalSearchEntry() { 'New card style' ) ), - symlinksOnWorktrees: findEntry( - translate( - 'auto.components.settings.experimental.search.78c2a8dc74', - 'Shared paths on worktrees' - ) - ), ephemeralVms: findEntry( translate('auto.components.settings.ephemeralVms.search.title', 'Per-Workspace Environments') ) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1a8a3f6ffe6..520e8cc29fc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -368,7 +368,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalNewWorktreeCardStyle: false, experimentalEphemeralVms: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, // Why: local desktop remains the default server until the user explicitly // selects a saved runtime environment. activeRuntimeEnvironmentId: null, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 1a77eb76d44..ee34b53ec3c 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -298,7 +298,6 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'experimentalWorktreeSymlinks', 'geminiCliOAuthEnabled', 'openAgentTabsInChatByDefault' ] as const satisfies readonly BooleanGlobalSettingsKey[] diff --git a/src/shared/types.ts b/src/shared/types.ts index fedd1623e46..7acf3a5e6ea 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -272,11 +272,9 @@ export type Repo = { importedExternalWorktreePaths?: string[] /** User permanently opted out of the new-external-worktree inbox for this repo. */ externalWorktreeDiscoverySuppressedAt?: number - /** Paths (relative to the primary checkout) that should be symlinked into - * newly created worktrees of this repo. Consumed only when the global - * `experimentalWorktreeSymlinks` flag is on — the per-repo list is the - * "what to link", the global flag is the "whether to link at all" switch. - * Undefined/empty means no symlinks are created for this repo. */ + /** Paths (relative to the primary checkout) that should be APFS clone-copied + * on macOS when possible, otherwise symlinked, into newly created worktrees. + * Undefined/empty means no shared paths are created for this repo. */ symlinkPaths?: string[] /** Durable sidebar-only repo organization. Execution remains repo-scoped. */ projectGroupId?: string | null @@ -2922,13 +2920,6 @@ export type GlobalSettings = { /** Legacy persisted key from the Experimental rollout. New writes use * compactWorktreeCards. */ experimentalCompactWorktreeCards?: boolean - /** Experimental: when creating a worktree, automatically symlink a - * user-configured set of files/folders from the primary checkout (e.g. - * `.env`, `node_modules`) into the new worktree. Opt-in while the - * configuration surface and edge cases (conflicts with existing paths, - * cleanup on worktree delete) are still being worked out. */ - experimentalWorktreeSymlinks: boolean - /** Active non-local runtime environment for client-routed RPC. `null` * preserves the current local desktop behavior. */ activeRuntimeEnvironmentId?: string | null From 2802f0419d402dbb04912bcfe4a2bc6fde670573 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:32:55 -0700 Subject: [PATCH 16/26] Fix browser annotation sends to active Windows agents (#8309) * fix: authorize guarded sends to active ConPTY agents * Fix stale-handle rejection using controller-less status checks and contr - Distinguish `terminal_handle_stale` (PTY rebind mid-check) from `terminal_exited` so callers get an accurate reason instead of a generic `terminal_not_writable`. - Move the PTY binding assertion to always run after the fresh foreground read, even when no controller is present, closing a gap where a rebind during a controller-less check went undetected. - Call `confirmForegroundProcess` through its owning controller instance so the method keeps its `this` receiver instead of being invoked unbound. - Update reliability gates and design doc to reflect the new test coverage and corrected error semantics. * rm design doc --- config/reliability-gates.jsonc | 165 +++++++++++++ src/main/ipc/pty.test.ts | 34 +++ src/main/ipc/pty.ts | 9 + src/main/runtime/orca-runtime.test.ts | 263 ++++++++++++++++++++- src/main/runtime/orca-runtime.ts | 88 +++++-- src/main/runtime/rpc/methods/terminal.ts | 39 ++- src/main/runtime/rpc/terminal-send.test.ts | 78 ++++++ 7 files changed, 652 insertions(+), 24 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7d8172cb5f..333eb7e8a14 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2114,6 +2114,171 @@ ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, + { + "id": "terminal-input.guarded-agent-send-authorization", + "title": "Guarded agent sends authorize only the confirmed exact PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-provider-contract", + "surfaces": [ + "terminal input", + "guarded note sends", + "agent status", + "PTY identity", + "foreground process confirmation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon" + ], + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8303" + ], + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "testFiles": [ + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/windows-conpty-process-membership.test.ts", + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/renderer/src/lib/active-agent-note-send.test.ts", + "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result", + "shell, non-agent, null, thrown, and missing confirmation remain fail-closed", + "title blockers and ordinary recognized agent evidence add no confirmation call", + "PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY", + "controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "a handle rebind during guarded status evaluation returns not writable and writes zero bytes", + "the callback's actual PTY must equal the handle binding before status can authorize a write" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "runtime confirmation reaches the provider owning the captured PTY", + "unsupported or missing providers return unavailable confirmation" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", + "unavailable process inspection is distinct from authoritative shell evidence" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "fresh confirmation is discarded when its owning local PTY exits" + ] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "exact ConPTY console membership comes from the fixed node-pty helper", + "malformed, incomplete, timed-out, and spawn-error membership reads fail closed" + ] + }, + { + "file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "assertions": [ + "daemons from before the fresh-confirmation RPC are rejected by protocol version" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "fresh confirmation bypasses cached PowerShell and waits for a post-request process scan" + ] + }, + { + "file": "src/renderer/src/lib/active-agent-note-send.test.ts", + "assertions": [ + "selected active-agent note sends retain guarded paste and submit routing" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "assertions": [ + "browser annotation send content routes through review-notes send so existing agent sessions remain selectable", + "both browser annotation send surfaces wire the shared menu content" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "result": "passed", + "durationSeconds": 9.58, + "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused runtime, RPC, PTY provider, and renderer routing units" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + }, + "promotionCriteria": [ + "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", + "Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.", + "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." + ], + "knownGaps": [ + "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", + "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", + "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", + "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." + ], + "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..94b047d8968 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3221,6 +3221,40 @@ describe('registerPtyHandlers', () => { }) }) + it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => { + const confirmForegroundProcess = vi.fn(async () => 'codex') + registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never) + setPtyOwnership('remote-pty', 'ssh-1') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') + deletePtyOwnership('remote-pty') + }) + + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { + registerSshPtyProvider('ssh-1', {} as never) + setPtyOwnership('unsupported-pty', 'ssh-1') + setPtyOwnership('missing-pty', 'missing-connection') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() + await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() + deletePtyOwnership('unsupported-pty') + deletePtyOwnership('missing-pty') + }) + it('rethrows non-not-found local provider shutdown failures', async () => { setLocalPtyProvider({ spawn: vi.fn(), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4dcc451324a..493cc494058 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3397,6 +3397,15 @@ export function registerPtyHandlers( return null } }, + confirmForegroundProcess: async (ptyId) => { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } + }, getCwd: async (ptyId) => { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..fdd1096529e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,10 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -1179,6 +1176,67 @@ const store = { getProjects: () => [] } +async function createExplicitAgentStatusHarness(options: { + getForegroundProcess: (ptyId: string) => Promise + confirmForegroundProcess?: (ptyId: string) => Promise + title?: string +}): Promise<{ + runtime: OrcaRuntimeService + handle: string + syncPty: (ptyId: string | null) => void +}> { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'working', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + confirmForegroundProcess: options.confirmForegroundProcess + }) + runtime.attachWindow(1) + const syncPty = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: options.title ?? 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId + } + ] + }) + } + syncPty('pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + return { runtime, handle: terminal.handle, syncPty } +} + function makeHeadlessTerminalLayout( ptyIdsByLeafId: Record ): TerminalLayoutSnapshot { @@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn(async () => 'claude') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(getForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + }) + + it('calls foreground confirmation with its controller receiver', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn( + async function (this: { getForegroundProcess: typeof getForegroundProcess }) { + return this.getForegroundProcess === getForegroundProcess ? 'codex' : null + } + ) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it.each([ + ['shell', async () => 'pwsh.exe'], + ['non-agent', async () => 'vim'], + ['unavailable', async () => null], + [ + 'failure', + async () => { + throw new Error('provider unavailable') + } + ] + ])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => { + const confirmForegroundProcess = vi.fn(confirm) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it('fails closed on a shell conflict when the controller cannot confirm it', async () => { + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + }) + + it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => { + const getForegroundProcess = vi.fn(async () => 'codex') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips both foreground reads when current title evidence blocks explicit hook state', async () => { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess, + title: 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips foreground reads for permission title and blocked wait evidence', async () => { + for (const blocked of ['title', 'wait'] as const) { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + runtime.onPtyData( + 'pty-1', + blocked === 'title' + ? '\x1b]0;Codex waiting for permission\x07' + : 'Hooks need review. Press enter to confirm\n', + Date.now() + 1000 + ) + getForegroundProcess.mockClear() + confirmForegroundProcess.mockClear() + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + } + }) + + it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => { + const foreground = deferred() + const getForegroundProcess = vi.fn(() => foreground.promise) + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + foreground.resolve('zsh') + + await expect(status).rejects.toThrow('terminal_handle_stale') + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('rejects a handle rebind while a controller-less status check yields', async () => { + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + runtime.setPtyController(null) + + const status = runtime.getTerminalAgentStatus(handle) + syncPty('pty-2') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the owning PTY exits', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + runtime.onPtyExit('pty-1', 0) + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_exited') + }) + it('reports permission from a title-derived action-required agent state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2cf08c69f4d..19d08a619ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1205,6 +1205,7 @@ type RuntimePtyController = { stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -10226,7 +10227,8 @@ export class OrcaRuntimeService { } async getTerminalAgentStatus(handle: string): Promise { - const terminal = this.getTerminalAgentStatusSnapshot(handle) + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = @@ -10250,7 +10252,8 @@ export class OrcaRuntimeService { // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle)) + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, @@ -10261,14 +10264,42 @@ export class OrcaRuntimeService { return { handle, isRunningAgent: true, status: terminal.titleStatus } } - return { - handle, - isRunningAgent: await this.isTerminalRunningAgent(handle), - status: null - } + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } } - private getTerminalAgentStatusSnapshot(handle: string): { + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + // Why: delayed process evidence belongs only to the PTY that started the + // read, while callers still rely on the established stale-handle contract. + throw new Error('terminal_handle_stale') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string + ): { waitText: string waitBlockedAt: number | null title: string | null @@ -10277,8 +10308,8 @@ export class OrcaRuntimeService { } { const pty = this.getLivePtyForHandle(handle) if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf @@ -10316,6 +10347,9 @@ export class OrcaRuntimeService { if (!leaf.ptyId) { throw new Error('terminal_gone') } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, @@ -10330,21 +10364,36 @@ export class OrcaRuntimeService { } } - private async terminalHasShellForegroundProcess(handle: string): Promise { + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } + let foregroundProcess: string | null try { - const pty = this.getLivePtyForHandle(handle) - const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - if (!ptyId) { - return false - } - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - return foregroundProcess !== null && isShellProcess(foregroundProcess) + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmationController = this.ptyController + if (!confirmationController?.confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( @@ -20876,7 +20925,8 @@ export class OrcaRuntimeService { // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { try { - return this.getTerminalAgentStatusSnapshot(handle).titleStatus + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9c39a65f456..7a9b9e448a6 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} + +function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} + function appendPendingMultiplexOutput( stream: TerminalMultiplexStream, data: string, @@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { throw new Error('terminal_guard_not_writable') } - const agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + let agentStatus + try { + agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + // Why: a send callback can race a pane reconnect; status evidence + // must never authorize the callback's replacement PTY. + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (!agentStatus.isRunningAgent) { throw new Error('terminal_guard_no_agent') } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 12e40c2c977..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -525,6 +525,84 @@ describe('terminal send RPC', () => { ) }) + it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => { + let boundPtyId = 'pty-1' + let statusCalls = 0 + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockImplementation(async () => { + statusCalls += 1 + if (statusCalls === 2) { + boundPtyId = 'pty-2' + } + return { + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + } + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write('pty-1', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + }) + + it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-2') + write('pty-2', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalled() + }) + it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), From 5092ba9fb873a0d3bf0cfd39b1279fc7ab4cbade Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:41:32 -0700 Subject: [PATCH 17/26] feat(resource-manager): show browser resources (#8267) --- .../ResourceUsageStatusSegment.rows.test.tsx | 27 ++++++++++ .../status-bar/ResourceUsageStatusSegment.tsx | 40 +++++++++++---- .../mergeSnapshotAndSessions.test.ts | 50 ++++++++++++++++++- .../status-bar/mergeSnapshotAndSessions.ts | 36 +++++++++++-- .../status-bar/resource-usage-merge-types.ts | 12 ++++- .../status-bar/resource-usage-open-slices.ts | 8 +++ 6 files changed, 159 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx index 57f77d1e472..b544399d9ba 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -4,6 +4,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import type { BrowserWorkspace } from '../../../../shared/types' import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' vi.mock('@/store', () => { @@ -64,6 +65,7 @@ function makeWorktree(overrides: Partial): UnifiedWorktreeRo hasLocalSamples: true, isRemote: false, sessions: [], + browsers: [], ...overrides } } @@ -131,4 +133,29 @@ describe('resource manager row presentation', () => { expect(container.querySelector('button[aria-label="Kill session orphan-a"]')).not.toBeNull() }) + + it('shows browsers as read-only workspace resources', () => { + renderWorktreeRow( + makeWorktree({ + browsers: [ + { + id: 'browser-1', + worktreeId: 'wt-1', + title: 'Orca docs', + url: 'https://docs.orca.dev', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } as BrowserWorkspace + ] + }) + ) + + expect(container.textContent).toContain('Orca docs') + expect(container.querySelector('.lucide-globe')).not.toBeNull() + expect(container.querySelector('button[aria-label^="Open browser"]')).toBeNull() + }) }) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 8d3ed2982e9..5c9e580b5fa 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -9,6 +9,7 @@ import { AlertTriangle, ChevronDown, ChevronRight, + Globe, LoaderCircle, MemoryStick, RotateCw, @@ -35,7 +36,7 @@ import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' -import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' +import type { AppMemory, BrowserWorkspace, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { isFolderRepo } from '../../../../shared/repo-kind' @@ -56,6 +57,7 @@ import { } from './resource-session-navigation' import { getResourceUsageAllWorktrees, + getResourceUsageBrowserTabsByWorktree, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -438,6 +440,18 @@ export function SessionRow({ ) } +function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { + const label = browser.title?.trim() || browser.label?.trim() || browser.url + return ( +
+ + {label} + + +
+ ) +} + // ─── Worktree row ─────────────────────────────────────────────────── export function WorktreeRow({ @@ -461,7 +475,7 @@ export function WorktreeRow({ onKillSession: (session: UnifiedSessionRow) => void navigateToTab: (tabId: string, paneKey: string | null) => void }): React.JSX.Element { - const hasSessions = worktree.sessions.length > 0 + const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 // Why: synthetic buckets (orphan/unattributed) have no sidebar target to // reveal. Real and SSH-resolved worktrees both qualify for navigation — // navigateToWorktree handles the no-store-record case internally by @@ -481,7 +495,7 @@ export function WorktreeRow({ return (
- {hasSessions ? ( + {hasResources ? (
) } @@ -776,6 +792,7 @@ export function ResourceUsageStatusSegment({ const repos = useAppStore((s) => getResourceUsageRepos(s, open)) const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open)) // Why: the closed trigger owns a scalar selector. Full binding maps stay // behind open sentinels so unchanged counts do not rerender the segment. const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open)) @@ -922,6 +939,10 @@ export function ResourceUsageStatusSegment({ }, [repos]) const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + const worktreeById = useMemo( + () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), + [allWorktrees] + ) const oldWorkspaceCount = useMemo(() => { const now = Date.now() @@ -954,7 +975,9 @@ export function ResourceUsageStatusSegment({ workspaceSessionReady, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById + repoRuntimeScopedById, + browserTabsByWorktree, + worktreeById }) : [], [ @@ -968,7 +991,9 @@ export function ResourceUsageStatusSegment({ workspaceSessionReady, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById + repoRuntimeScopedById, + browserTabsByWorktree, + worktreeById ] ) @@ -1252,10 +1277,7 @@ export function ResourceUsageStatusSegment({
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc', - 'Resource Manager - Terminals' - )} + {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index e542f90a818..831d4ad90e3 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { MemorySnapshot, TerminalTab, WorktreeMemory } from '../../../../shared/types' +import type { + BrowserWorkspace, + MemorySnapshot, + TerminalTab, + Worktree, + WorktreeMemory +} from '../../../../shared/types' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { DaemonSession, MergeContext } from './resource-usage-merge-types' @@ -59,6 +65,48 @@ describe('mergeSnapshotAndSessions', () => { expect(mergeSnapshotAndSessions(null, [], baseCtx())).toEqual([]) }) + it('includes browser-only workspaces in their repo', () => { + const worktree = { + id: 'orca::/Users/me/browser-only', + repoId: 'orca', + displayName: 'browser-only' + } as Worktree + const browser = { + id: 'browser-1', + worktreeId: worktree.id, + title: 'Orca docs', + url: 'https://docs.orca.dev', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } as BrowserWorkspace + const out = mergeSnapshotAndSessions( + null, + [], + baseCtx({ + repoDisplayNameById: new Map([['orca', 'ORCA']]), + worktreeById: new Map([[worktree.id, worktree]]), + browserTabsByWorktree: { [worktree.id]: [browser] } + }) + ) + + expect(out[0]).toMatchObject({ + repoId: 'orca', + repoName: 'ORCA', + worktrees: [ + { + worktreeId: worktree.id, + worktreeName: 'browser-only', + sessions: [], + browsers: [browser] + } + ] + }) + }) + it('passes through snapshot worktrees with numeric metrics and hasLocalSamples', () => { const wt: WorktreeMemory = { worktreeId: 'orca::/Users/me/Triton', diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index e4d12744f90..7c7f5499f71 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -218,7 +218,8 @@ export function mergeSnapshotAndSessions( history: wt.history, hasLocalSamples: true, isRemote: isRepoRemote(wt.repoId), - sessions + sessions, + browsers: [] }) } } @@ -276,7 +277,8 @@ export function mergeSnapshotAndSessions( history: [], hasLocalSamples: false, isRemote: repoIsRemote, - sessions: [] + sessions: [], + browsers: [] } repo.worktrees.push(row) } @@ -294,7 +296,35 @@ export function mergeSnapshotAndSessions( }) } - // ── Step 3: per-repo aggregates. Remote children are identified by the + // ── Step 3: add browser resources, including browser-only workspaces. + for (const [worktreeId, browsers] of Object.entries(ctx.browserTabsByWorktree ?? {})) { + const worktree = ctx.worktreeById?.get(worktreeId) + if (!worktree || browsers.length === 0) { + continue + } + const repoName = ctx.repoDisplayNameById.get(worktree.repoId) || worktree.repoId + const repo = ensureRepo(worktree.repoId, repoName) + let row = findWorktreeRow(repo, worktreeId) + if (!row) { + row = { + worktreeId, + worktreeName: worktree.displayName, + repoId: worktree.repoId, + repoName, + cpu: null, + memory: null, + history: [], + hasLocalSamples: false, + isRemote: isRepoRemote(worktree.repoId), + sessions: [], + browsers: [] + } + repo.worktrees.push(row) + } + row.browsers = browsers + } + + // ── Step 4: per-repo aggregates. Remote children are identified by the // repo's connectionId, not by missing data — `!hasLocalSamples` would // mislabel warm-reattached local PTYs. The aggregate still skips rows // we can't sample (worktree.cpu === null) so the numbers stay honest. diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 7e208d51817..a3adf534443 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,9 @@ -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { + BrowserWorkspace, + TerminalLayoutSnapshot, + TerminalTab, + Worktree +} from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -33,6 +38,7 @@ export type UnifiedWorktreeRow = { /** Why: repo connectionId, not sample presence, drives the remote chip. */ isRemote: boolean sessions: UnifiedSessionRow[] + browsers: BrowserWorkspace[] } export type UnifiedProjectGroup = { @@ -62,4 +68,8 @@ export type MergeContext = { repoConnectionIdById: Map /** Repo runtime-host scope by repo id (missing == keep row). */ repoRuntimeScopedById: Map + /** Browser inventory is open-only; the Resource Manager never scans it in the background. */ + browserTabsByWorktree?: Record + /** Canonical worktrees keep browser-only workspace rows out of synthetic buckets. */ + worktreeById?: ReadonlyMap } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index 7b5e7ba6d5b..c4bed3ecd15 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -5,6 +5,7 @@ const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} +const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] @@ -36,6 +37,13 @@ export function getResourceUsageRuntimePaneTitlesByTabId( return open ? state.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID } +export function getResourceUsageBrowserTabsByWorktree( + state: Pick, + open: boolean +): AppState['browserTabsByWorktree'] { + return open ? state.browserTabsByWorktree : EMPTY_BROWSER_TABS_BY_WORKTREE +} + export function getResourceUsageRepos( state: Pick, open: boolean From 6be8687c343123d6d59fc6a89c289f84362eff06 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:49:49 -0700 Subject: [PATCH 18/26] feat(status-bar): notify upgraded users usage meters show % used (#8319) * feat(status-bar): notify upgraded users usage meters show % used Show a one-time status-bar callout when upgraded profiles still use the new percent-used default. Brand-new profiles and users who already chose remaining stay quiet; dismissing or changing the setting is permanent. * Add settings deep-link to expand Appearance's Window accordion for one-s - Replaces the searchQuery-based redirect (fragile, flashed filter UI) with a dedicated appearanceAccordionDeepLink store field that force-opens the correct accordion and scrolls to the target row - Rebuilds the status-bar change notice as a plain elevated card instead of a Popover, since PopoverContent's glass/backdrop-filter defaults fought the opaque callout styling and needed heavy overrides - Simplifies the light/dark card CSS tokens accordingly * Reposition status-bar usage-change notice via fixed-position portal Portal the one-shot callout to document.body with fixed positioning anchored via getBoundingClientRect, since the status-bar's overflow-hidden flex ancestors clipped or mispositioned the previous in-tree absolute layout. * Refine status-bar usage notice styling and test coverage - Replace hand-tuned light/dark card colors with existing design tokens (--popover, --border) and the documented floating elevation, so the callout stays in sync with the design system instead of duplicating its own palette - Add tests covering dismiss via X button, "Got it", and Escape - Scope the foreground-process confirm assertion to the pane's ptyId so an unrelated pane's delayed confirm can't cause a false failure --- src/main/persistence.test.ts | 44 ++++ src/main/persistence.ts | 22 ++ src/main/runtime/rpc/methods/client-ui.ts | 1 + src/renderer/src/assets/main.css | 25 ++ .../settings/AppearancePane.test.tsx | 4 +- .../components/settings/AppearancePane.tsx | 27 ++- .../AppearanceWindowSidebarSection.tsx | 2 + .../src/components/settings/Settings.tsx | 20 +- ...appearance-usage-percentage-search.test.ts | 16 ++ .../appearance-usage-percentage-search.ts | 19 ++ .../src/components/status-bar/StatusBar.tsx | 16 +- ...sagePercentageDisplayChangeNotice.test.tsx | 225 ++++++++++++++++++ .../UsagePercentageDisplayChangeNotice.tsx | 215 +++++++++++++++++ .../terminal-pane/pty-connection.test.ts | 4 +- src/renderer/src/store/slices/ui.test.ts | 26 +- src/renderer/src/store/slices/ui.ts | 44 +++- src/shared/constants.ts | 3 + src/shared/types.ts | 4 + ...e-percentage-display-change-notice.test.ts | 118 +++++++++ .../usage-percentage-display-change-notice.ts | 45 ++++ .../e2e-completed-onboarding-profile.ts | 5 +- 21 files changed, 872 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts create mode 100644 src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx create mode 100644 src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx create mode 100644 src/shared/usage-percentage-display-change-notice.test.ts create mode 100644 src/shared/usage-percentage-display-change-notice.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4e921151e4f..6fefe99834c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -562,6 +562,50 @@ describe('Store', () => { expect(ui.setupGuideSidebarDismissed).toBe(false) expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true) expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) + // Why: brand-new profiles never saw remaining-as-default. + expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true) + }) + + it('surfaces the usage percentage display change notice for upgraded profiles', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + // Why: omit the notice key so load resolves eligibility for existing profiles. + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + }) + + it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + usagePercentageDisplay: 'remaining', + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('defaults minimizeToTrayOnClose to false when unset', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 19cce1bbacb..2fc2704f488 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -105,6 +105,8 @@ import { } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' +import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -3306,6 +3308,25 @@ export class Store { ) { this.loadNeedsSave = true } + // Why: only upgraded profiles that still use the new default get + // the one-time usage-display change notice; brand-new profiles and + // users who already chose remaining stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + this.loadNeedsSave = true + } return { ...defaults.ui, // Why: missing card properties should follow the persisted card @@ -3319,6 +3340,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 271fcefad75..d262890d8d7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -239,6 +239,7 @@ const UiUpdate = z trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 56841b77245..25a5a4e751b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1209,6 +1209,31 @@ background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar)); } +/* Why: one-shot status-bar callout is portaled fixed above the meters. + Opaque popover surface (not glass) so titlebar/terminal chrome cannot + bleed through; use design tokens + the documented floating elevation. */ +.status-bar-change-notice-card { + border: 1px solid var(--border); + background: var(--popover); + color: var(--popover-foreground); + /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */ + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); +} + +/* Why: caret points down at the usage meters this notice explains. */ +.status-bar-change-notice-card::after { + content: ''; + position: absolute; + left: 1.25rem; + bottom: -6px; + width: 12px; + height: 12px; + transform: rotate(45deg); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--popover); +} + /* Why: the first-run Mobile Emulator intro sits directly under that menu row; an upward caret aligned to the phone icon reads as a callout, not a footer. */ .mobile-emulator-tab-intro-callout--menu { diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index dc5a4abd1aa..ca68ff7e47c 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -23,7 +23,9 @@ const mocks = vi.hoisted(() => ({ usagePercentageDisplay: 'used' as 'used' | 'remaining', setUsagePercentageDisplay: vi.fn(), recordFeatureInteraction: vi.fn(), - setWorktreeCardMode: vi.fn() + setWorktreeCardMode: vi.fn(), + appearanceAccordionDeepLink: null as 'interface' | 'terminal' | 'window' | null, + clearAppearanceAccordionDeepLink: vi.fn() } })) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 92792621533..631059d6d3a 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useState } from 'react' +import { useLayoutEffect, useState } from 'react' import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' @@ -10,6 +10,7 @@ import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' import { useAppStore } from '../../store' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percentage-search' import { getAppIconEntries, getAppearancePaneSearchEntries, @@ -75,6 +76,10 @@ export function AppearancePane({ warpThemes }: AppearancePaneProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) + const appearanceAccordionDeepLink = useAppStore((state) => state.appearanceAccordionDeepLink) + const clearAppearanceAccordionDeepLink = useAppStore( + (state) => state.clearAppearanceAccordionDeepLink + ) const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const isWebClient = isWebClientLocation() // Why: the system tray behavior is desktop-Electron Windows-only; a Windows @@ -84,6 +89,26 @@ export function AppearancePane({ const [manuallyOpenSection, setManuallyOpenSection] = useState( 'interface' ) + + // Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar; + // expand that accordion before Settings scrolls so the row is actually visible. + useLayoutEffect(() => { + if (!appearanceAccordionDeepLink) { + return + } + setManuallyOpenSection(appearanceAccordionDeepLink) + clearAppearanceAccordionDeepLink() + // Why: accordion expand is layout-synchronous; scroll on the next frame so + // the target has non-zero height when Settings (or this fallback) scrolls. + const frameId = requestAnimationFrame(() => { + document + .getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID) + ?.scrollIntoView({ block: 'nearest' }) + }) + return () => { + cancelAnimationFrame(frameId) + } + }, [appearanceAccordionDeepLink, clearAppearanceAccordionDeepLink]) const interfaceTitle = translate( 'auto.components.settings.AppearancePane.interfaceTitle', 'Interface' diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 93b9f94535e..3228c54669c 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -18,6 +18,7 @@ import { getStatusBarToggles, getUsagePercentageDisplayEntry } from './appearance-search' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percentage-search' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, @@ -130,6 +131,7 @@ export function AppearanceWindowSidebarSection({ {showStatusBarControls ? (
signal + 1) } @@ -892,7 +901,16 @@ function Settings(): React.JSX.Element { const scrollTargetId = pendingScrollTargetRef.current const pendingNavSectionId = pendingNavSectionRef.current - if (scrollTargetId && pendingNavSectionId && settingsSearchQuery.trim() !== '') { + // Why: subsection deep links (scrollTarget ≠ pane id) must not keep a + // leftover search filter that can hide the target row. Pane-level deep + // links may intentionally pair with a filter (e.g. Appearance + "Usage + // percentages") so accordion sections force-open to the matching control. + if ( + scrollTargetId && + pendingNavSectionId && + scrollTargetId !== pendingNavSectionId && + settingsSearchQuery.trim() !== '' + ) { setSettingsSearchQuery('') return } diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts new file mode 100644 index 00000000000..fdb30dd8d80 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { + resolveAppearanceAccordionDeepLink, + USAGE_PERCENTAGE_DISPLAY_SETTING_ID +} from './appearance-usage-percentage-search' + +describe('resolveAppearanceAccordionDeepLink', () => { + it('maps the usage percentage row to the Window accordion', () => { + expect(resolveAppearanceAccordionDeepLink(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)).toBe('window') + }) + + it('returns null for unknown or missing section ids', () => { + expect(resolveAppearanceAccordionDeepLink(undefined)).toBeNull() + expect(resolveAppearanceAccordionDeepLink('something-else')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts index 9375c7c9fd1..b7bbc49218b 100644 --- a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -2,6 +2,25 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +/** Stable Settings deep-link / scroll target for the Used/Remaining control. */ +export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display' + +/** Appearance accordion keys that can be force-opened for nested deep links. */ +export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window' + +/** + * Map a Settings subsection id to the Appearance accordion that must be open + * before the row is visible (collapsed accordion content is not user-visible). + */ +export function resolveAppearanceAccordionDeepLink( + sectionId: string | undefined +): AppearanceAccordionSection | null { + if (sectionId === USAGE_PERCENTAGE_DISPLAY_SETTING_ID) { + return 'window' + } + return null +} + export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({ title: translate( 'auto.components.settings.appearance.search.usagePercentageDisplayTitle', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 281c968fa2f..b3cc8a922c1 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -63,6 +63,7 @@ import { UpdateStatusSegment } from './UpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta' +import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { useShortcutLabel } from '@/hooks/useShortcutLabel' @@ -2015,7 +2016,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const showPorts = statusBarItems.includes('ports') const showFloatingTerminalToggle = floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' - const anyVisible = + // Why: which usage-meter children actually render — excludes resource-usage + // and other non-meter status items so the % display change callout only + // opens when there is a real meter cluster to anchor to. + const hasVisibleUsageMeters = showClaude || showCodex || showGemini || @@ -2023,8 +2027,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showKimi || showAntigravity || showMiniMax || - showGrok || - showResourceUsage + showGrok + const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage @@ -2082,7 +2086,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele ) : null ) : ( - <> + // Why: one-time usage-display change callout anchors to this cluster so + // it sits next to the meters the user is confused by, not a global toast. + {showClaude && ( )} @@ -2155,7 +2161,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} - + )} {anyVisible && !isEmptyUsageState && ( diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx new file mode 100644 index 00000000000..cb8fea8a750 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx @@ -0,0 +1,225 @@ +// @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 { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search' + +const storeState = { + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + dismissUsagePercentageDisplayChangeNotice: vi.fn(), + statusBarVisible: true, + activeModal: 'none' as string, + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { + getState: () => storeState + } + ) +})) + +describe('UsagePercentageDisplayChangeNotice', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.useFakeTimers() + storeState.persistedUIReady = true + storeState.usagePercentageDisplayChangeNoticeDismissed = false + storeState.statusBarVisible = true + storeState.activeModal = 'none' + storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn() + storeState.openSettingsPage = vi.fn() + storeState.openSettingsTarget = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + // Why: fixed positioning reads getBoundingClientRect; happy-dom needs a + // non-zero layout box so the portal card is measured and mounted. + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 24, + y: 700, + top: 700, + left: 24, + bottom: 724, + right: 200, + width: 176, + height: 24, + toJSON: () => ({}) + }) satisfies DOMRect + }) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + document.querySelectorAll('.status-bar-change-notice-card').forEach((node) => node.remove()) + vi.useRealTimers() + }) + + it('portals the callout above the usage-meter anchor after a short delay', () => { + act(() => { + root.render( + + usage-meters + + ) + }) + + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + act(() => { + vi.advanceTimersByTime(1_800) + }) + const card = document.querySelector('.status-bar-change-notice-card') as HTMLElement | null + expect(card).not.toBeNull() + expect(card?.parentElement).toBe(document.body) + expect(card?.style.left).toBe('24px') + // viewport height default in happy-dom is often 768; bottom = 768 - 700 + 10 + expect(card?.style.bottom).toBeTruthy() + expect(document.body.textContent).toContain('Usage now shows % used') + expect(container.textContent).toContain('usage-meters') + }) + + it('does not open when no usage meters are visible', () => { + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('does not open when the notice was already dismissed', () => { + storeState.usagePercentageDisplayChangeNoticeDismissed = true + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('does not open while another modal is open', () => { + storeState.activeModal = 'feature-tips' + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('deep-links to the Usage percentages setting without a search filter', () => { + const callOrder: string[] = [] + storeState.openSettingsPage = vi.fn(() => { + callOrder.push('openSettingsPage') + }) + storeState.openSettingsTarget = vi.fn(() => { + callOrder.push('openSettingsTarget') + }) + storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn() + + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(1_800) + }) + + const openSettingsButton = Array.from( + document.querySelectorAll('.status-bar-change-notice-card button') + ).find((button) => button.textContent === 'Open Settings') + expect(openSettingsButton).toBeTruthy() + act(() => { + openSettingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(callOrder).toEqual(['openSettingsPage', 'openSettingsTarget']) + expect(storeState.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'appearance', + repoId: null, + sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalled() + }) + + it('dismisses from the X button', () => { + openNotice() + + const dismissButton = document.querySelector( + '.status-bar-change-notice-card button[aria-label="Dismiss"]' + ) + expect(dismissButton).toBeTruthy() + act(() => { + dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + it('dismisses from Got it', () => { + openNotice() + + const gotItButton = Array.from( + document.querySelectorAll('.status-bar-change-notice-card button') + ).find((button) => button.textContent === 'Got it') + expect(gotItButton).toBeTruthy() + act(() => { + gotItButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + it('dismisses on Escape', () => { + openNotice() + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + function openNotice(): void { + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(1_800) + }) + expect(document.querySelector('.status-bar-change-notice-card')).not.toBeNull() + } +}) diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx new file mode 100644 index 00000000000..5c7263130cc --- /dev/null +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx @@ -0,0 +1,215 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { BarChart3, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { shouldShowUsagePercentageDisplayChangeNotice } from '../../../../shared/usage-percentage-display-change-notice' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search' + +// Why: let startup modals settle before the status-bar callout competes for focus. +const SHOW_DELAY_MS = 1_800 +// Why: gap between the top of the status-bar meters and the bottom of the card. +const ANCHOR_GAP_PX = 10 +const CARD_WIDTH_PX = 320 + +type AnchorPosition = { + bottom: number + left: number +} + +function openUsagePercentageSettings(): void { + const store = useAppStore.getState() + // Why: openSettingsPage wipes any leftover search; do not re-apply a search + // filter — deep-link to the stable row id and let Appearance expand Window. + store.openSettingsPage() + store.openSettingsTarget({ + pane: 'appearance', + repoId: null, + sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID + }) +} + +function measureAnchorPosition(anchor: HTMLElement): AnchorPosition { + const rect = anchor.getBoundingClientRect() + const maxLeft = Math.max(8, window.innerWidth - CARD_WIDTH_PX - 8) + return { + // Why: fixed + bottom keeps the card glued above the meters as the window + // resizes; CSS bottom is distance from the viewport bottom edge. + bottom: Math.max(8, window.innerHeight - rect.top + ANCHOR_GAP_PX), + left: Math.min(Math.max(8, rect.left), maxLeft) + } +} + +/** + * One-time elevated callout anchored above the status-bar usage meters after + * the default flipped from remaining → used. Permanent dismiss only. + * + * Why fixed + portal: status-bar ancestors use overflow-hidden flex shells, so + * in-tree absolute positioning clips or attaches to the wrong containing block. + */ +export function UsagePercentageDisplayChangeNotice({ + children, + hasVisibleUsageMeters +}: { + children: ReactNode + // Why: StatusBar owns which meter children actually render (status-bar items, + // CLI detection, MiniMax/Grok durability). Don't re-derive empty-state here. + hasVisibleUsageMeters: boolean +}): React.JSX.Element { + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const dismissed = useAppStore((s) => s.usagePercentageDisplayChangeNoticeDismissed) + const dismiss = useAppStore((s) => s.dismissUsagePercentageDisplayChangeNotice) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) + const activeModal = useAppStore((s) => s.activeModal) + const [delayElapsed, setDelayElapsed] = useState(false) + const anchorRef = useRef(null) + const [anchorPosition, setAnchorPosition] = useState(null) + + const eligible = shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady, + usagePercentageDisplayChangeNoticeDismissed: dismissed, + statusBarVisible, + hasVisibleUsageMeters, + activeModal + }) + + useEffect(() => { + if (!eligible) { + setDelayElapsed(false) + return + } + const timer = window.setTimeout(() => { + setDelayElapsed(true) + }, SHOW_DELAY_MS) + return () => { + window.clearTimeout(timer) + } + }, [eligible]) + + const open = eligible && delayElapsed + + useLayoutEffect(() => { + if (!open) { + setAnchorPosition(null) + return + } + const anchor = anchorRef.current + if (!anchor) { + return + } + + const update = (): void => { + setAnchorPosition(measureAnchorPosition(anchor)) + } + update() + + // Why: meters reflow when the status bar goes compact/icon-only or the + // window resizes; keep the fixed card locked to the live anchor box. + const observer = new ResizeObserver(update) + observer.observe(anchor) + window.addEventListener('resize', update) + return () => { + observer.disconnect() + window.removeEventListener('resize', update) + } + }, [open]) + + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault() + dismiss() + } + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [dismiss, open]) + + const openSettings = (): void => { + dismiss() + openUsagePercentageSettings() + } + + const card = + open && anchorPosition + ? createPortal( +
+
+
+
+ +
+ {translate( + 'auto.components.status.bar.UsagePercentageDisplayChangeNotice.title', + 'Usage now shows % used' + )} +
+
+

+ {translate( + 'auto.components.status.bar.UsagePercentageDisplayChangeNotice.body', + 'Prefer remaining? Change it in Settings.' + )} +

+
+ +
+
+ + +
+
, + document.body + ) + : null + + return ( + <> +
+ {children} +
+ {card} + + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c2815c88496..ae29c1e3589 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17599,7 +17599,9 @@ describe('connectPanePty', () => { binding.sampleForegroundAgentOnFocus() await vi.advanceTimersByTimeAsync(10_000) - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + // Scope to this pane's pty id: a delayed confirm for another test's + // default `tab-pty` pane can fire during this advance and is not our subject. + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) } finally { restoreUserAgent() } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 9e2e82e0cb4..3e5b8d928e5 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1268,12 +1268,36 @@ describe('createUISlice hydratePersistedUI', () => { store.getState().setUsagePercentageDisplay('used') expect(store.getState().usagePercentageDisplay).toBe('used') - expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' }) + // Why: adapting the control also permanently dismisses the one-time change notice. + expect(setUI).toHaveBeenCalledWith({ + usagePercentageDisplay: 'used', + usagePercentageDisplayChangeNoticeDismissed: true + }) + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(true) store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' })) expect(store.getState().usagePercentageDisplay).toBe('remaining') }) + it('hydrates and dismisses the usage percentage display change notice', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ usagePercentageDisplayChangeNoticeDismissed: false })) + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + + store.getState().dismissUsagePercentageDisplayChangeNotice() + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(true) + expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplayChangeNoticeDismissed: true }) + + setUI.mockClear() + store.getState().dismissUsagePercentageDisplayChangeNotice() + expect(setUI).not.toHaveBeenCalled() + }) + it('defaults invalid usage percentage display values to used', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a0f6aa0db73..bcc32db82f6 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -747,6 +747,16 @@ export type UISlice = { } | null openSettingsTarget: (target: NonNullable) => void clearSettingsTarget: () => void + /** + * One-shot Appearance accordion to expand for nested Settings deep links + * (e.g. Usage percentages lives under Window & Sidebar). Cleared when + * Appearance consumes it. + */ + appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null + setAppearanceAccordionDeepLink: ( + section: NonNullable + ) => void + clearAppearanceAccordionDeepLink: () => void activeModal: | 'none' | 'create-worktree' @@ -827,6 +837,8 @@ export type UISlice = { dismissMobileEmulatorAgentSetup: () => void projectOrderManualDefaultNoticeDismissed: boolean dismissProjectOrderManualDefaultNotice: () => void + usagePercentageDisplayChangeNoticeDismissed: boolean + dismissUsagePercentageDisplayChangeNotice: () => void usageEmptyStateDismissed: boolean dismissUsageEmptyState: () => void groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' @@ -1497,6 +1509,9 @@ export const createUISlice: StateCreator = (set, get) settingsNavigationTarget: null, openSettingsTarget: (target) => set({ settingsNavigationTarget: target }), clearSettingsTarget: () => set({ settingsNavigationTarget: null }), + appearanceAccordionDeepLink: null, + setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), + clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }), activeModal: 'none', modalData: {}, @@ -1922,6 +1937,17 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) return { projectOrderManualDefaultNoticeDismissed: true } }), + // Why: defaults true so pre-hydration / brand-new sessions never flash the + // change notice before persistence resolves eligibility. + usagePercentageDisplayChangeNoticeDismissed: true, + dismissUsagePercentageDisplayChangeNotice: () => + set((s) => { + if (s.usagePercentageDisplayChangeNoticeDismissed) { + return s + } + window.api.ui.set({ usagePercentageDisplayChangeNoticeDismissed: true }).catch(console.error) + return { usagePercentageDisplayChangeNoticeDismissed: true } + }), usageEmptyStateDismissed: false, dismissUsageEmptyState: () => set((s) => { @@ -2122,8 +2148,18 @@ export const createUISlice: StateCreator = (set, get) usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, setUsagePercentageDisplay: (display) => { const normalized = normalizeUsagePercentageDisplay(display) - window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error) - set({ usagePercentageDisplay: normalized }) + // Why: changing the control is the discovery path; permanently dismiss the + // one-time change notice so it does not reappear after the user adapted. + window.api.ui + .set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + .catch(console.error) + set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) }, workspacePortScan: null, workspacePortScansByKey: {}, @@ -2467,6 +2503,10 @@ export const createUISlice: StateCreator = (set, get) mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, projectOrderManualDefaultNoticeDismissed: ui.projectOrderManualDefaultNoticeDismissed === true, + // Why: load() resolves this for existing vs new profiles; treat only + // explicit true as dismissed so a false from migration still surfaces. + usagePercentageDisplayChangeNoticeDismissed: + ui.usagePercentageDisplayChangeNoticeDismissed === true, // Why: default false when undefined so existing users still see the CTA; // only an explicit dismissal persists true. usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 520e8cc29fc..a14ec445fa4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -511,6 +511,9 @@ export function getDefaultUIState(): PersistedUIState { // Why: brand-new profiles never saw recent project ordering; only upgraded // profiles get the one-time sidebar notice on first launch. projectOrderManualDefaultNoticeDismissed: true, + // Why: brand-new profiles never saw percent-remaining as the default; only + // upgraded profiles get the one-time usage-display change notice. + usagePercentageDisplayChangeNoticeDismissed: true, workspaceCleanup: { dismissals: {} }, featureTipsSeenIds: [], featureInteractions: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 7acf3a5e6ea..232811d5981 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3348,6 +3348,10 @@ export type PersistedUIState = { /** One-shot rollout notice for manual project ordering becoming the default. * Absent or true means the sidebar callout stays hidden. */ projectOrderManualDefaultNoticeDismissed?: boolean + /** One-shot notice that status-bar usage meters now show percent used (not + * remaining). Absent is resolved on load: brand-new profiles default to + * dismissed; upgraded profiles see the notice once. */ + usagePercentageDisplayChangeNoticeDismissed?: boolean /** User-hidden empty-state usage CTA in the status bar. Permanently hides the * "Connect AI accounts to see usage" prompt even if all providers are later * disconnected — a dismissed teaching nudge stays dismissed. */ diff --git a/src/shared/usage-percentage-display-change-notice.test.ts b/src/shared/usage-percentage-display-change-notice.test.ts new file mode 100644 index 00000000000..4942aefe900 --- /dev/null +++ b/src/shared/usage-percentage-display-change-notice.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + resolveUsagePercentageDisplayChangeNoticeDismissed, + shouldShowUsagePercentageDisplayChangeNotice +} from './usage-percentage-display-change-notice' + +describe('resolveUsagePercentageDisplayChangeNoticeDismissed', () => { + it('keeps an explicit dismissal', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: true, + rawUsagePercentageDisplay: undefined, + isExistingProfile: true + }) + ).toBe(true) + }) + + it('hides the notice for brand-new profiles', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: undefined, + isExistingProfile: false + }) + ).toBe(true) + }) + + it('hides the notice when the user already chose remaining', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: 'remaining', + isExistingProfile: true + }) + ).toBe(true) + }) + + it('shows the notice for upgraded profiles still on used/missing default', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: undefined, + isExistingProfile: true + }) + ).toBe(false) + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: 'used', + isExistingProfile: true + }) + ).toBe(false) + }) +}) + +describe('shouldShowUsagePercentageDisplayChangeNotice', () => { + it('requires ready UI, undismissed state, visible usage meters, and no modal', () => { + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(true) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: false, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: true, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: false, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: false, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'feature-tips' + }) + ).toBe(false) + }) +}) diff --git a/src/shared/usage-percentage-display-change-notice.ts b/src/shared/usage-percentage-display-change-notice.ts new file mode 100644 index 00000000000..caeaa6ccfb6 --- /dev/null +++ b/src/shared/usage-percentage-display-change-notice.ts @@ -0,0 +1,45 @@ +/** + * Resolve whether the one-time "usage now shows percent used" notice is + * already dismissed for this profile. + * + * - Explicit dismissal stays dismissed. + * - Brand-new profiles never saw percent-remaining as the default, so they + * should not get a change notice. + * - Users who already chose "remaining" have found the setting; no need to + * teach the flip. + * - Upgraded profiles still on the new default ("used"/missing) get the notice. + */ +export function resolveUsagePercentageDisplayChangeNoticeDismissed(args: { + rawDismissed: unknown + rawUsagePercentageDisplay: unknown + isExistingProfile: boolean +}): boolean { + if (args.rawDismissed === true) { + return true + } + if (!args.isExistingProfile) { + return true + } + // Why: choosing remaining is the discovery path; re-teaching the default flip + // would only interrupt someone who already adapted. + if (args.rawUsagePercentageDisplay === 'remaining') { + return true + } + return false +} + +export function shouldShowUsagePercentageDisplayChangeNotice(args: { + persistedUIReady: boolean + usagePercentageDisplayChangeNoticeDismissed: boolean + statusBarVisible: boolean + hasVisibleUsageMeters: boolean + activeModal: string +}): boolean { + return ( + args.persistedUIReady && + !args.usagePercentageDisplayChangeNoticeDismissed && + args.statusBarVisible && + args.hasVisibleUsageMeters && + args.activeModal === 'none' + ) +} diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 6161f1579a3..3ffefb1e770 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -38,7 +38,10 @@ export function getE2ECompletedOnboardingProfile() { ), contextualToursSeenIds: [...SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS], contextualToursAutoEligible: false, - projectOrderManualDefaultNoticeDismissed: true + projectOrderManualDefaultNoticeDismissed: true, + // Why: E2E profiles model completed existing users and should not be + // interrupted by the usage-display change toast covering the UI under test. + usagePercentageDisplayChangeNoticeDismissed: true } } } From ac8879c789df450eafb9883a13b015e5a57504e7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:51:52 -0700 Subject: [PATCH 19/26] feat(markdown): support HTML superscript links (#8307) * feat(markdown): support HTML superscript links * Add HTML superscript citation links to the rich markdown editor - Introduce marked-based tokenizer support for `` citation links, with a shared editor context so key/link handlers can resolve selection status and open an action bubble for citation atoms (which markdown setLink/unsetLink can't edit). - Extract clipboard-write logic shared by cut and cut-visual-line into rich-markdown-clipboard-write.ts, and surface a cut-limit error when clipboard readback fails. - Fix MarkdownPreview same-file anchor scrolling to run before the unknown-ownership guard so ambiguous folder-workspace ownership still scrolls within the open document. - Keep search's replace-disabled state derived from live matches instead of a stale snapshot. * Update lockfile to dedupe stale package versions after dependency resolu - Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install - Aligns transitive deps to the single resolved versions already in use elsewhere in the tree --- package.json | 1 + pnpm-lock.yaml | 195 +----- .../src/assets/rich-markdown-editor.css | 21 + .../LinearIssueMarkdownDescriptionEditor.tsx | 26 +- ...nPreview.link-routing.interaction.test.tsx | 109 +++- ...rkdownPreview.toc-visibility-gate.test.tsx | 8 +- .../src/components/editor/MarkdownPreview.tsx | 108 +++- .../components/editor/RichMarkdownEditor.tsx | 37 +- .../editor/RichMarkdownEditorSurface.tsx | 12 + .../editor/RichMarkdownLinkBubble.tsx | 130 +++- .../editor/RichMarkdownSearchBar.tsx | 38 +- .../html-character-reference-decoder.ts | 22 + .../isolated-markdown-extension-for-tests.ts | 9 + .../editor/markdown-dirty-state.test.ts | 4 +- .../editor/markdown-internal-links.ts | 96 +-- .../editor/markdown-preview-links.test.ts | 35 +- .../editor/markdown-preview-links.ts | 9 +- .../editor/markdown-round-trip.test.ts | 17 +- .../components/editor/markdown-round-trip.ts | 26 +- .../components/editor/raw-markdown-html.ts | 328 +++++----- .../editor/rich-markdown-citation-keyboard.ts | 40 ++ .../rich-markdown-clipboard-serialization.ts | 17 + .../editor/rich-markdown-clipboard-write.ts | 37 ++ .../editor/rich-markdown-commands.test.ts | 3 +- .../editor/rich-markdown-cut-handler.ts | 28 +- .../editor/rich-markdown-cut.test.ts | 50 +- .../rich-markdown-details-keyboard.test.ts | 3 +- .../editor/rich-markdown-doc-link-dom.ts | 20 + .../editor/rich-markdown-doc-link.ts | 462 +++++++------- .../rich-markdown-editor-click-routing.ts | 45 +- .../rich-markdown-editor-config.test.ts | 10 + .../editor/rich-markdown-editor-config.ts | 43 +- ...ch-markdown-empty-paragraph-delete.test.ts | 4 +- .../editor/rich-markdown-extensions.ts | 35 +- ...-markdown-html-superscript-link-context.ts | 73 +++ ...h-markdown-html-superscript-link-source.ts | 293 +++++++++ ...ich-markdown-html-superscript-link.test.ts | 593 ++++++++++++++++++ .../rich-markdown-html-superscript-link.ts | 218 +++++++ .../editor/rich-markdown-key-handler.test.ts | 16 +- .../editor/rich-markdown-key-handler.ts | 53 +- .../editor/rich-markdown-large-text-paste.ts | 6 +- .../rich-markdown-link-clipboard.test.ts | 42 ++ .../editor/rich-markdown-link-clipboard.ts | 18 + .../rich-markdown-link-shortcut.test.ts | 130 ++++ .../editor/rich-markdown-link-shortcut.ts | 64 ++ .../rich-markdown-list-continuation.test.ts | 8 +- .../editor/rich-markdown-local-image.test.ts | 3 +- .../editor/rich-markdown-normalize.test.ts | 4 +- .../editor/rich-markdown-paste-handler.ts | 40 +- .../rich-markdown-review-annotations.ts | 26 +- .../editor/rich-markdown-review-line-count.ts | 18 + .../rich-markdown-review-text-ranges.test.ts | 4 +- .../rich-markdown-review-text-ranges.ts | 81 +-- .../editor/rich-markdown-search.test.ts | 8 +- .../components/editor/rich-markdown-search.ts | 110 +++- .../rich-markdown-selected-link-actions.ts | 162 +++++ ...ich-markdown-source-owning-cut-feedback.ts | 11 + .../rich-markdown-source-owning-slice.ts | 102 +++ .../editor/rich-markdown-source-transport.ts | 86 +++ .../rich-markdown-tab-key-handler.test.ts | 30 +- .../editor/rich-markdown-visible-text-map.ts | 132 ++++ .../editor/rich-markdown-visual-line.ts | 18 +- .../components/editor/tiptap-marked-facade.ts | 82 +++ .../components/editor/useLinkBubble.test.tsx | 93 +++ .../src/components/editor/useLinkBubble.ts | 50 +- .../editor/useRichMarkdownEditorInstance.ts | 14 +- .../editor/useRichMarkdownProgrammaticSync.ts | 20 +- .../editor/useRichMarkdownSearch.ts | 13 +- .../useRichMarkdownSuperscriptLinkSetup.ts | 59 ++ .../github/GitHubMarkdownComposer.tsx | 27 +- src/renderer/src/i18n/locales/en.json | 24 +- src/renderer/src/i18n/locales/es.json | 24 +- src/renderer/src/i18n/locales/ja.json | 24 +- src/renderer/src/i18n/locales/ko.json | 24 +- src/renderer/src/i18n/locales/zh.json | 24 +- .../src/lib/connection-context.test.ts | 21 + src/renderer/src/lib/connection-context.ts | 81 +-- .../src/lib/connection-owner-resolution.ts | 100 +++ .../src/lib/http-link-routing.test.ts | 104 ++- src/renderer/src/lib/http-link-routing.ts | 47 +- src/renderer/src/store/slices/editor.test.ts | 78 ++- src/renderer/src/store/slices/editor.ts | 63 +- 82 files changed, 4301 insertions(+), 1048 deletions(-) create mode 100644 src/renderer/src/components/editor/html-character-reference-decoder.ts create mode 100644 src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-citation-keyboard.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-write.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link-dom.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link-context.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link-source.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-clipboard.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-line-count.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-selected-link-actions.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-owning-cut-feedback.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-transport.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-visible-text-map.ts create mode 100644 src/renderer/src/components/editor/tiptap-marked-facade.ts create mode 100644 src/renderer/src/components/editor/useLinkBubble.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts create mode 100644 src/renderer/src/lib/connection-owner-resolution.ts diff --git a/package.json b/package.json index 5cb55814377..473bf8ab92b 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "marked": "^17.0.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b48ad9b4f29..ebb3aa40135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -399,10 +402,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -415,10 +414,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -437,10 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,20 +440,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -487,26 +468,14 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -515,11 +484,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -577,26 +541,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -3457,6 +3409,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -4354,14 +4307,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4446,6 +4391,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4642,6 +4588,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4967,6 +4914,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5330,6 +5278,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} @@ -6740,12 +6689,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6774,14 +6717,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6792,7 +6727,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6810,26 +6745,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6840,15 +6766,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6860,7 +6777,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6869,27 +6786,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6897,10 +6808,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -6923,7 +6830,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6953,7 +6860,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) @@ -6962,30 +6869,12 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6998,11 +6887,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7127,7 +7011,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7148,9 +7032,9 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.4 + fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -8992,24 +8876,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/cacheable-request@6.0.3': dependencies: @@ -9369,8 +9253,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)': dependencies: @@ -10588,24 +10471,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -12019,7 +11889,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12089,7 +11959,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12689,7 +12558,7 @@ snapshots: shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.66.0 @@ -12703,7 +12572,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.6 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12788,7 +12657,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index ccf79ad3638..38c625f0d37 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1026,6 +1026,27 @@ color: #58a6ff; } +.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] { + line-height: 0; +} + +.rich-markdown-editor .rich-markdown-html-superscript-link { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-html-superscript-link { + color: #58a6ff; +} + +.rich-markdown-mod-held + .rich-markdown-editor + .rich-markdown-html-superscript-link[data-actionable] { + cursor: pointer; +} + .rich-markdown-editor img { max-width: 100%; border-radius: 8px; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 5e45c6e71da..f8f11c9567b 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' @@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -function createLinearIssueMarkdownExtensions() { - const extensions = createRichMarkdownExtensions() +function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { + const extensions = createRichMarkdownExtensions({ codec }) return [ ...extensions, Placeholder.configure({ @@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({ const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) + // Why: changing language recreates Tiptap and re-registers tokenizers, so it + // must also receive a fresh private Marked registry instead of growing one. + const codec = useMemo(() => { + void language + return createRichMarkdownEditorCodec() + }, [language]) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. void language - return createLinearIssueMarkdownExtensions() - }, [language]) + return createLinearIssueMarkdownExtensions(codec) + }, [codec, language]) const editor = useEditor( { immediatelyRender: false, extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), + content: encodeRawMarkdownHtmlForRichEditor(value, codec), contentType: 'markdown', editable: !disabled, editorProps: { @@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({ onChange(nextValue) } }, - [language] + [codec, language] ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) @@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({ // Why: Linear remains the source of truth when the selected issue changes // or an optimistic save is reverted; keep the rich view aligned with it. - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), { + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), { contentType: 'markdown', emitUpdate: false }) lastEditorMarkdownRef.current = value - }, [editor, value]) + }, [codec, editor, value]) return (
true) +const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined })) +const targetConnectionOwners = vi.hoisted(() => new Map()) +const worktreeLookup = vi.hoisted(() => ({ + value: [] as { id: string; path: string; diffComments: never[] }[] +})) +const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false }))) // Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful // of action functions. None of the actions fire on the http path under test. @@ -31,6 +39,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -44,14 +55,22 @@ vi.mock('@/store', () => { ) return { useAppStore } }) -vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/store/slices/worktree-helpers', () => ({ + findWorktreeById: (_worktrees: unknown, id: string) => + worktreeLookup.value.find((worktree) => worktree.id === id) ?? null +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (settings: unknown) => settings })) vi.mock('@/runtime/runtime-file-client', () => ({ - statRuntimePath: vi.fn(async () => ({ isDirectory: false })) + statRuntimePath: statRuntimePathMock +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId) +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) @@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { ;(window as unknown as { api: unknown }).api = { shell: { openUrl: openUrlMock, - openFileUri: vi.fn(), - pathExists: vi.fn(async () => true) + openFileUri: openFileUriMock, + pathExists: pathExistsMock }, ui: { writeClipboardText: vi.fn(async () => true) } } @@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { createBrowserTabMock.mockClear() setActiveWorktreeMock.mockClear() openUrlMock.mockClear() + openFileUriMock.mockClear() + pathExistsMock.mockClear() + connectionOwner.value = null + targetConnectionOwners.clear() + worktreeLookup.value = [] + storeState.worktreesByRepo = {} + storeState.openMarkdownPreview.mockClear() + statRuntimePathMock.mockClear() }) afterEach(() => { @@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { container.remove() }) - function render(): HTMLAnchorElement { + function render( + content = '[example](https://example.com)', + expectedHref = 'https://example.com', + sourceWorktreeId = 'wt-1', + filePath = '/repo/docs/README.md' + ): HTMLAnchorElement { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root.render( ) }) - const anchor = container.querySelector('a[href="https://example.com"]') + const anchor = Array.from(container.querySelectorAll('a')).find( + (candidate) => candidate.getAttribute('href') === expectedHref + ) if (!anchor) { throw new Error('expected a rendered http anchor') } @@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') expect(createBrowserTabMock).not.toHaveBeenCalled() }) + + it('keeps system-browser HTTP and file links inert while ownership is unknown', () => { + connectionOwner.value = undefined + const httpAnchor = render() + click(httpAnchor, { metaKey: true, shiftKey: true }) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + + act(() => { + root.unmount() + }) + container.remove() + const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md') + click(fileAnchor, { metaKey: true, shiftKey: true }) + expect(pathExistsMock).not.toHaveBeenCalled() + expect(openFileUriMock).not.toHaveBeenCalled() + }) + + it('keeps same-path file links on the explicit SSH source worktree', async () => { + const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] } + const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] } + worktreeLookup.value = [localWorktree, sshWorktree] + storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] } + connectionOwner.value = 'ssh-1' + targetConnectionOwners.set('local-wt', null) + targetConnectionOwners.set('ssh-wt', 'ssh-1') + const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md') + + await act(async () => { + anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(statRuntimePathMock).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }), + '/srv/repo/child.md' + ) + expect(storeState.openMarkdownPreview).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }), + { anchor: null } + ) + }) + + it('renders a raw HTML superscript citation and routes it like a Markdown link', () => { + const source = '[12]' + const anchor = render(source) + + expect(anchor.parentElement?.tagName).toBe('SUP') + expect(anchor.textContent).toBe('[12]') + expect(container.textContent).not.toContain('') + click(anchor, { metaKey: true }) + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 46301826f01..23f66fa9ee5 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -28,6 +28,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b77aaa33fb5..38d146c1312 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' @@ -68,7 +69,7 @@ import { setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' @@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot( function findWorktreeForMarkdownPreviewPath( worktreesByRepo: Record, - absolutePath: string + absolutePath: string, + acceptsWorktree: (worktree: Worktree) => boolean = () => true ): Worktree | null { let bestMatch: Worktree | null = null let bestMatchLength = -1 for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { - if (relativePathInsideRoot(worktree.path, absolutePath) !== null) { + if ( + acceptsWorktree(worktree) && + relativePathInsideRoot(worktree.path, absolutePath) !== null + ) { const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath( worktree.path ).length @@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath( return bestMatch } +function findMarkdownPreviewTargetWorktree( + worktreesByRepo: Record, + absolutePath: string, + sourceWorktree: Worktree | null, + sourceOwner: HttpLinkSourceOwner +): Worktree | null { + if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) { + return sourceWorktree + } + return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => { + const connectionId = getConnectionIdForFile(worktree.id, absolutePath) + if (sourceOwner.kind === 'local') { + return connectionId === null + } + if (sourceOwner.kind === 'ssh') { + return connectionId === sourceOwner.connectionId + } + return false + }) +} + export function resolveMarkdownPreviewSourceWorktree( worktreesByRepo: Record, sourceWorktreeId: string | null | undefined, @@ -498,9 +524,26 @@ export default function MarkdownPreview({ ) const allDiffComments = sourceWorktree?.diffComments const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId - const sourceConnectionId = sourceRoutingWorktreeId - ? (getConnectionId(sourceRoutingWorktreeId) ?? null) - : null + const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim() + const sourceConnectionIdSelector = useMemo( + () => + createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, { + skip: Boolean(runtimeOwnerId) + }), + [filePath, runtimeOwnerId, sourceRoutingWorktreeId] + ) + const sourceConnectionId = useAppStore(sourceConnectionIdSelector) + const sourceOwner = useMemo( + () => + runtimeOwnerId + ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId } + : sourceConnectionId === undefined + ? { kind: 'unknown' } + : sourceConnectionId === null + ? { kind: 'local' } + : { kind: 'ssh', connectionId: sourceConnectionId }, + [runtimeOwnerId, sourceConnectionId] + ) const worktreeRoot = sourceWorktree?.path ?? (sourceRoutingWorktreeId @@ -1254,6 +1297,9 @@ export default function MarkdownPreview({ // dangling in-worktree .md, pre-check existence so the user sees a // toast instead of the silent no-op from shell.openFileUri. if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { + if (sourceOwner.kind === 'unknown') { + return + } const osTarget = getMarkdownPreviewLinkTarget(href, filePath) if (!osTarget) { return @@ -1267,7 +1313,12 @@ export default function MarkdownPreview({ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openHttpLink( parsed.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1326,7 +1377,12 @@ export default function MarkdownPreview({ // handled above; this path only sees non-escape-hatch clicks.) openHttpLink( target.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1347,12 +1403,25 @@ export default function MarkdownPreview({ ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } : parseLineTarget(target.hash) + // Why: same-file anchors need no ownership/filesystem resolution (e.g. + // `./README.md#heading` when this file is README.md). Run before the + // unknown-ownership guard so ambiguous folder-workspace ownership still + // scrolls within the open document. if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } - const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (sourceOwner.kind === 'unknown') { + return + } + + const targetWorktree = findMarkdownPreviewTargetWorktree( + worktreesByRepo, + absolutePath, + sourceWorktree, + sourceOwner + ) if (!targetWorktree) { if (sourceRoutingWorktreeId && worktreeRoot) { // Why: floating markdown files are owned by a synthetic workspace, @@ -1362,7 +1431,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) return } @@ -1384,8 +1454,15 @@ export default function MarkdownPreview({ return } - const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath) + if (relativePath === null) { + return + } const language = detectLanguage(absolutePath) + const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath) + if (targetConnectionId === undefined) { + return + } try { const stats = await statRuntimePath( { @@ -1395,7 +1472,7 @@ export default function MarkdownPreview({ ), worktreeId: targetWorktree.id, worktreePath: targetWorktree.path, - connectionId: getConnectionId(targetWorktree.id) ?? undefined + connectionId: targetConnectionId ?? undefined }, absolutePath ) @@ -1512,7 +1589,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) } @@ -1673,6 +1751,8 @@ export default function MarkdownPreview({ setMarkdownViewMode, setPendingEditorReveal, sourceConnectionId, + sourceOwner, + sourceWorktree, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot, diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c5954052906..1c0cb2734d2 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { Editor } from '@tiptap/react' +import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { useLocalImagePick } from './useLocalImagePick' import { useRichMarkdownSearch } from './useRichMarkdownSearch' import type { LinkBubbleState } from './RichMarkdownLinkBubble' @@ -25,6 +24,11 @@ import { runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' +import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup' +import { + formatSelectedHtmlSuperscriptLinkStatus, + getSelectedHtmlSuperscriptLinkStatus +} from './rich-markdown-selected-link-actions' type RichMarkdownEditorProps = { fileId: string @@ -86,9 +90,11 @@ export default function RichMarkdownEditor({ const allDiffComments = useAppStore((s): DiffComment[] | undefined => selectWorktreeDiffComments(s, worktreeId) ) - const worktreeRoot = useAppStore( - (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null - ) + const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({ + filePath, + runtimeEnvironmentId, + worktreeId + }) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') @@ -196,6 +202,8 @@ export default function RichMarkdownEditor({ ) const editor = useRichMarkdownEditorInstance({ + codec, + htmlSuperscriptLinkContext, content, filePath, worktreeId, @@ -240,6 +248,13 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only + // citation NodeSelections would leave aria status stale without useEditorState. + const selectedCitationStatus = useEditorState({ + editor, + selector: (snapshot) => + getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext) + }) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization @@ -265,6 +280,7 @@ export default function RichMarkdownEditor({ }) useRichMarkdownProgrammaticSync({ + codec, content, docLinkMenuSetter: menu.setDocLinkMenu, editor, @@ -289,12 +305,14 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, toggleLinkFromToolbar } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, worktreeRoot, - runtimeEnvironmentId + runtimeEnvironmentId, + htmlSuperscriptLinkContext }) useEffect(() => { @@ -370,6 +388,12 @@ export default function RichMarkdownEditor({ showTableOfContents={showTableOfContents} searchState={searchState} searchActions={searchActions} + citationStatus={ + selectedCitationStatus + ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus) + : '' + } + linkBubbleOwnerId={codec.transport.key} linkBubbleActions={{ dismissLinkBubble: () => { setLinkBubble(null) @@ -379,6 +403,7 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, setIsEditingLink }} onToggleLink={toggleLinkFromToolbar} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index f5283616abb..fd619247c2a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = { matchCase: boolean matchCount: number replaceQuery: string + replaceDisabled: boolean searchQuery: string searchInputRef: React.RefObject wholeWord: boolean @@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = { toggleReplaceMode: () => void toggleWholeWord: () => void } + citationStatus: string + linkBubbleOwnerId: string linkBubbleActions: { dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void handleLinkOpen: () => void + handleLinkCopy: () => void setIsEditingLink: (editing: boolean) => void } onToggleLink: () => void @@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({ showTableOfContents, searchState, searchActions, + citationStatus, + linkBubbleOwnerId, linkBubbleActions, onToggleLink, onImagePick, @@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({ matchCount={searchState.matchCount} query={searchState.searchQuery} replaceQuery={searchState.replaceQuery} + replaceDisabled={searchState.replaceDisabled} searchInputRef={searchState.searchInputRef} wholeWord={searchState.wholeWord} onClose={searchActions.closeSearch} @@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({ onEditStart={() => linkBubbleActions.setIsEditingLink(true)} onEditCancel={linkBubbleActions.handleLinkEditCancel} onOpen={linkBubbleActions.handleLinkOpen} + onCopy={linkBubbleActions.handleLinkCopy} + ownerId={linkBubbleOwnerId} /> ) : null} + + {citationStatus} + {slashMenu ? ( void onEditCancel: () => void onOpen: () => void + onCopy: () => void + ownerId?: string portalToDocument?: boolean } @@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({ onEditStart, onEditCancel, onOpen, + onCopy, + ownerId, portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { const bubbleRef = useRef(null) @@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
{ // Prevent editor blur when clicking bubble buttons, but let inputs @@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({ e.preventDefault() } }} - onKeyDown={(e) => e.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Escape') { + event.preventDefault() + onDismiss() + anchorElement?.querySelector('[contenteditable="true"]')?.focus() + return + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + const buttons = Array.from( + bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? [] + ) + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (currentIndex === -1 || buttons.length === 0) { + return + } + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus() + }} > {isEditing ? ( @@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({ {linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href} - - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({ // actions must portal to the app layer to remain above the right sidebar. return portalToDocument ? createPortal(bubble, document.body) : bubble } + +function LinkBubbleAction({ + children, + disabled = false, + label, + onClick +}: { + children: React.ReactNode + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index ca7fd042a83..9ea8d17e068 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = { matchCount: number query: string replaceQuery: string + replaceDisabled: boolean searchInputRef: React.RefObject wholeWord: boolean onClose: () => void @@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({ matchCount, query, replaceQuery, + replaceDisabled, searchInputRef, wholeWord, onClose, @@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({ // Why: surface the same replace shortcut the source editor uses so the toggle // is discoverable; reads the user's effective binding, formatted per platform. const replaceShortcut = useOptionalShortcutLabel('editor.replace') + const readOnlyExplanationId = React.useId() if (!isOpen) { return null @@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({ } const noMatches = matchCount === 0 + const readOnlyReplaceExplanation = translate( + 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly', + 'Preserved rich content is read-only in rich mode.' + ) const toggleReplaceLabel = isReplaceMode ? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace') : translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace') @@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({ 'auto.components.editor.RichMarkdownSearchBar.44682b4159', 'Replace in rich markdown editor' )} + aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined} />
+ {replaceDisabled ? ( + + {readOnlyReplaceExplanation} + + ) : null}
) : null}
diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts new file mode 100644 index 00000000000..e8bd9d1689d --- /dev/null +++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts @@ -0,0 +1,22 @@ +export type HtmlAttributeQuote = '"' | "'" | null + +export function decodeHtmlTextCharacterReferences(value: string): string { + // Why: a